From d2376169439b44984ae92bd9173253a3949488e1 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 14:26:52 -0400 Subject: [PATCH 1/7] feat(ui): snow rain mode, rain panel size presets, and taller default 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. --- README.md | 18 ++ internal/config/config_test.go | 30 +++ internal/config/defaults.go | 9 +- internal/config/loader.go | 3 + internal/config/types.go | 41 ++- internal/ui/config_view.go | 61 +++-- internal/ui/rain_bg.go | 77 +++++- internal/ui/rain_bg_test.go | 63 +++++ internal/ui/repo_selector.go | 77 +++++- internal/ui/snow_scene.go | 432 ++++++++++++++++++++++++++++++++ internal/ui/view_layout_test.go | 10 +- 11 files changed, 770 insertions(+), 51 deletions(-) create mode 100644 internal/ui/snow_scene.go diff --git a/README.md b/README.md index 98bfe48..5e5ac5c 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,24 @@ rain_animation_mode = "garden" 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 index 1c973c6..de72ac4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,9 +9,39 @@ import ( "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 index cd3c569..64270b5 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -37,6 +37,7 @@ func DefaultConfig() Config { UI: UIConfig{ ShowRainAnimation: true, RainAnimationMode: UIRainAnimationBasic, + RainPanelSize: UIRainPanelComfortable, ShowStartupQuote: true, StartupQuoteBehavior: UIQuoteBehaviorRefresh, StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec, @@ -114,10 +115,14 @@ mainline_patterns = [] 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 diff --git a/internal/config/loader.go b/internal/config/loader.go index fe89ec5..483adde 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -69,6 +69,8 @@ func LoadWithOptions(opts LoadOptions) (*Config, error) { return nil, fmt.Errorf("invalid config: %w", err) } + cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize) + return &cfg, nil } @@ -92,6 +94,7 @@ func setDefaults(v *viper.Viper) { 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) diff --git a/internal/config/types.go b/internal/config/types.go index 43c4a3b..732cc8a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,6 +1,8 @@ // Package config defines the git-rain configuration schema and related constants. package config +import "strings" + // Config represents the complete git-rain configuration type Config struct { Global GlobalConfig `mapstructure:"global" toml:"global"` @@ -57,10 +59,15 @@ type UIConfig struct { 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"` @@ -127,8 +134,38 @@ const ( 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{ diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index f9266be..7f683bc 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -42,6 +42,12 @@ var configRows = []configRow{ 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{ @@ -58,8 +64,12 @@ var configRows = []configRow{ {label: "Custom hex palette", kind: configRowComingSoon}, } +// firstGardenVisibleRow is the visible index of the first garden-only row +// (after "Rain animation mode" and "Rain panel size"). +const firstGardenVisibleRow = 6 + // Garden settings rows appear in the menu only when rain mode is garden, -// directly under "Rain animation mode" (see logicalRowIndex). +// directly under "Rain panel size" (see logicalRowIndex). 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"}}, @@ -84,11 +94,11 @@ func logicalRowIndex(visibleI int, cfg *config.Config) int { if g == 0 { return visibleI } - if visibleI < 5 { + if visibleI < firstGardenVisibleRow { return visibleI } - if visibleI < 5+g { - return len(configRows) + (visibleI - 5) + if visibleI < firstGardenVisibleRow+g { + return len(configRows) + (visibleI - firstGardenVisibleRow) } return visibleI - g } @@ -194,28 +204,30 @@ func configRowValue(visibleI int, cfg *config.Config) string { } return cfg.UI.RainAnimationMode case 5: + return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize) + case 6: if cfg.UI.ShowStartupQuote { return "true" } return "false" - case 6: - return cfg.UI.StartupQuoteBehavior case 7: - return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec) + return cfg.UI.StartupQuoteBehavior case 8: + return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec) + case 9: if cfg.UI.RainTickMS <= 0 { return strconv.Itoa(config.DefaultUIRainTickMS) } return strconv.Itoa(cfg.UI.RainTickMS) - case 9: - return cfg.UI.ColorProfile case 10: - return "coming soon" + return cfg.UI.ColorProfile case 11: - return gardenGrowthPaceLabel(cfg) + return "coming soon" case 12: - return gardenSeedRateLabel(cfg) + return gardenGrowthPaceLabel(cfg) case 13: + return gardenSeedRateLabel(cfg) + case 14: return gardenOffspringLabel(cfg) } return "" @@ -234,7 +246,7 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { 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,18 +270,20 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { } case 4: cfg.UI.RainAnimationMode = opts[idx] - case 6: - cfg.UI.StartupQuoteBehavior = opts[idx] + case 5: + cfg.UI.RainPanelSize = opts[idx] case 7: + cfg.UI.StartupQuoteBehavior = opts[idx] + case 8: sec, err := strconv.Atoi(opts[idx]) if err == nil && sec > 0 { cfg.UI.StartupQuoteIntervalSec = sec } - case 8: - applyRainTickChange(cfg, opts, dir) case 9: + applyRainTickChange(cfg, opts, dir) + case 10: cfg.UI.ColorProfile = opts[idx] - case 11: + case 12: switch opts[idx] { case "calm": cfg.UI.GardenGrowthPace = 1.32 @@ -278,7 +292,7 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { default: cfg.UI.GardenGrowthPace = 0 } - case 12: + case 13: switch opts[idx] { case "rare": cfg.UI.GardenSeedRate = 0.06 @@ -287,7 +301,7 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { default: cfg.UI.GardenSeedRate = 0 } - case 13: + case 14: switch opts[idx] { case "few": cfg.UI.GardenOffspringMin = 1 @@ -497,9 +511,10 @@ func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorMo 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 index 71d4ce4..22c2699 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -66,7 +66,7 @@ type RainDrop struct { ColorIdx int Age int MaxAge int - Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.) + Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.) IsSeed bool // garden mode: seed vs rain } @@ -364,6 +364,17 @@ type RainBackground struct { 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 } // NewRainBackground creates a new rain background @@ -412,9 +423,12 @@ func (rb *RainBackground) Reset() { 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 +461,10 @@ func (rb *RainBackground) spawnDrop(minY int) { } } } + 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, @@ -595,7 +613,7 @@ func (rb *RainBackground) gardenMaxFlyingSkySeeds(relief float64) int { if reliefEff > 1 { reliefEff = 1 } - ceiling := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) + ceiling := int(0.5 + float64(lo) + (float64(hi-lo))*reliefEff) if ceiling < 1 { ceiling = 1 } @@ -663,9 +681,9 @@ func (rb *RainBackground) Update() { 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 +769,12 @@ func (rb *RainBackground) Update() { } 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) { + rb.SnowGround[p.X]++ + rb.snowNoteFlakeLand(p.X) + p.Age = p.MaxAge + } } // Remove dead drops (off screen or expired) @@ -775,7 +799,7 @@ func (rb *RainBackground) Update() { } // 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 +807,10 @@ func (rb *RainBackground) Update() { rb.gardenAdvancePlots() rb.gardenMaybeFinishStorm() } + + if rb.Mode == config.UIRainAnimationSnow { + rb.snowAdvanceScene() + } } // Render returns the rain background as a string @@ -816,6 +844,8 @@ func (rb *RainBackground) Render() string { 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 +1294,24 @@ func RenderRainWave(width, frame int, mode string, gardenSunny bool) string { 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 +1369,9 @@ func rainPaletteForMode(mode string) []lipgloss.Color { if mode == config.UIRainAnimationMatrix { return matrixRainColors } + if mode == config.UIRainAnimationSnow { + return snowRainColors + } if mode == config.UIRainAnimationGarden { return gardenRainColors } @@ -1367,3 +1418,15 @@ var gardenRainColors = []lipgloss.Color{ 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 index eac4cfd..ae4492a 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -1,6 +1,7 @@ package ui import ( + "math/rand" "strings" "testing" @@ -285,3 +286,65 @@ func TestGardenBackgroundFinishesStorm(t *testing.T) { 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) + rand.Seed(42) + 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) + rand.Seed(7) + sum0 := 0 + for _, v := range rb.SnowGround { + sum0 += v + } + for i := 0; i < 400; 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) + rand.Seed(1) + 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 index b2d1c9d..e0f151a 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -186,13 +186,10 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder) animMode := config.UIRainAnimationBasic - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) - - return RepoSelectorModel{ + base := RepoSelectorModel{ repos: repos, cursor: 0, selected: selected, - rainBg: rainBg, spinner: s, windowWidth: 80, windowHeight: 40, @@ -209,6 +206,13 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat startupQuoteVisible: true, quoteTickActive: true, } + defCfg := config.DefaultConfig() + base.cfg = &defCfg + rainH := base.clampedRainBackgroundHeight() + base.rainBg = NewRainBackground(resolveRainBackgroundWidth(80), rainH, animMode) + base.applyGardenTuning(base.rainBg) + base.cfg = nil + return base } // NewRepoSelectorModelStream creates a model in streaming mode. @@ -256,14 +260,10 @@ func NewRepoSelectorModelStream( } bgW := resolveRainBackgroundWidth(80) - rainBg := NewRainBackground(bgW, 5, animMode) - rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) - - return RepoSelectorModel{ + base := RepoSelectorModel{ repos: nil, cursor: 0, selected: make(map[int]bool), - rainBg: rainBg, spinner: s, windowWidth: 80, windowHeight: 40, @@ -287,6 +287,11 @@ func NewRepoSelectorModelStream( startupQuoteVisible: showStartupQuote, quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0, } + rainH := base.clampedRainBackgroundHeight() + rainBg := NewRainBackground(bgW, rainH, animMode) + rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) + base.rainBg = rainBg + return base } func (m RepoSelectorModel) Init() tea.Cmd { @@ -338,7 +343,8 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.windowWidth = msg.Width m.windowHeight = msg.Height bgW := resolveRainBackgroundWidth(msg.Width) - m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode) + rainH := m.clampedRainBackgroundHeight() + m.rainBg = NewRainBackground(bgW, rainH, m.rainAnimationMode) m.applyGardenTuning(m.rainBg) m = m.withClampedPathScroll() m.scrollOffset = m.clampScroll(m.scrollOffset, m.cursor, m.repoListVisibleCount(), len(m.repos)) @@ -546,8 +552,18 @@ func (m RepoSelectorModel) rainVisible() bool { if !m.showRain { return false } - // Need at least enough rows: rain bg (5) + wave (1) + blank (1) + title (1) + list (1) = 9 - return m.windowHeight >= 9 + if m.rainBg == nil { + return false + } + mainCap := m.mainViewMeasuredRepoListCapacity() + if m.mainViewPanelOuterHeight(mainCap) > m.windowHeight || mainCap < 1 { + return false + } + igCap := m.ignoredMeasuredListCapacity() + if m.ignoredViewPanelOuterHeight(igCap) > m.windowHeight || igCap < 1 { + return false + } + return true } func (m RepoSelectorModel) quoteVisible() bool { @@ -715,6 +731,43 @@ func resolveRainBackgroundWidth(terminalWidth int) int { return w } +func rainPanelPresetRowsFromCfg(cfg *config.Config) int { + if cfg == nil { + return config.RainPanelRows(config.UIRainPanelComfortable) + } + return config.RainPanelRows(cfg.UI.RainPanelSize) +} + +// rainPanelFitsHeight reports whether the bordered main and ignored panels fit +// in the window when the animation canvas uses height h. +func (m RepoSelectorModel) rainPanelFitsHeight(h int) bool { + bgW := resolveRainBackgroundWidth(m.windowWidth) + dup := m + dup.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode) + dup.applyGardenTuning(dup.rainBg) + mainCap := dup.mainViewMeasuredRepoListCapacity() + if dup.mainViewPanelOuterHeight(mainCap) > dup.windowHeight || mainCap < 1 { + return false + } + igCap := dup.ignoredMeasuredListCapacity() + if dup.ignoredViewPanelOuterHeight(igCap) > dup.windowHeight || igCap < 1 { + return false + } + return true +} + +// clampedRainBackgroundHeight returns the animation row count from config +// presets, reduced if necessary so the TUI panel fits the terminal. +func (m RepoSelectorModel) clampedRainBackgroundHeight() int { + preset := rainPanelPresetRowsFromCfg(m.cfg) + for h := preset; h >= 3; h-- { + if m.rainPanelFitsHeight(h) { + return h + } + } + return 3 +} + func (m RepoSelectorModel) renderRainWaveStrip(width int) string { sunny := m.rainBg != nil && m.rainAnimationMode == config.UIRainAnimationGarden && m.rainBg.GardenSunny return RenderRainWave(width, m.frameIndex, m.rainAnimationMode, sunny) diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go new file mode 100644 index 0000000..53b81a4 --- /dev/null +++ b/internal/ui/snow_scene.go @@ -0,0 +1,432 @@ +package ui + +import ( + "math/rand" + + "github.com/charmbracelet/lipgloss" + "github.com/git-rain/git-rain/internal/config" +) + +type snowTree struct { + x int + h int + frost int + birthFrame int +} + +type snowSmoke struct { + x, y int + age int +} + +const ( + snowmanPhaseNone = iota + snowmanPhaseBaseDot + snowmanPhaseBaseSmall + snowmanPhaseBaseMed + snowmanPhaseBaseLarge + snowmanPhaseHeadDot + snowmanPhaseHeadRound + snowmanPhaseFace + snowmanPhasePipe + snowmanPhaseHat +) + +const snowCabinW = 7 + +var snowflakeChars = [...]string{"·", "˙", "⁚", "*", "`", ","} + +func (rb *RainBackground) initSnowScene() { + if rb.Width <= 0 { + return + } + rb.SnowGround = make([]int, rb.Width) + rb.SnowTrees = rb.SnowTrees[:0] + rb.SnowSmoke = rb.SnowSmoke[:0] + rb.SnowmanPhase = snowmanPhaseNone + rb.SnowmanX = 0 + rb.SnowmanBuild = 0 + rb.SnowmanAux = 0 + rb.SnowCabinFrost = 0 + rb.SnowCabinLeft = rb.Width/2 - snowCabinW/2 + if rb.SnowCabinLeft < 0 { + rb.SnowCabinLeft = 0 + } + if rb.SnowCabinLeft+snowCabinW > rb.Width { + rb.SnowCabinLeft = rb.Width - snowCabinW + if rb.SnowCabinLeft < 0 { + rb.SnowCabinLeft = 0 + } + } + 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) + } + } +} + +func (rb *RainBackground) snowChimneyTop() (int, int) { + roofY := rb.Height - 4 + if roofY < 1 { + roofY = 1 + } + cx := rb.SnowCabinLeft + 5 + if cx >= rb.Width { + cx = rb.Width - 1 + } + return cx, roofY - 1 +} + +func (rb *RainBackground) snowNoteFlakeLand(x int) { + if rb.Mode != config.UIRainAnimationSnow || rb.SnowGround == nil { + return + } + if rb.SnowmanPhase != snowmanPhaseNone && rb.SnowmanPhase < snowmanPhaseFace { + d := absInt(x - rb.SnowmanX) + if d <= 2 { + rb.SnowmanBuild++ + } + } +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} + +func (rb *RainBackground) snowTotalGround() int { + if rb.SnowGround == nil { + return 0 + } + t := 0 + for _, v := range rb.SnowGround { + t += v + } + return t +} + +func (rb *RainBackground) snowAdvanceScene() { + if rb.Width <= 0 || rb.Height <= 0 { + return + } + + if rb.Frame%40 == 0 && rb.SnowCabinFrost < 3 { + rb.SnowCabinFrost++ + } + for i := range rb.SnowTrees { + if rb.SnowTrees[i].frost >= 3 { + continue + } + if (rb.Frame+rb.SnowTrees[i].birthFrame)%55 == 0 { + rb.SnowTrees[i].frost++ + } + } + + if rb.SnowmanPhase == snowmanPhaseNone && rb.Width >= 18 { + if rb.snowTotalGround() >= rb.Width*4 { + rb.SnowmanPhase = snowmanPhaseBaseDot + rb.SnowmanBuild = 0 + rb.SnowmanAux = 0 + } + } + + switch rb.SnowmanPhase { + case snowmanPhaseBaseDot: + if rb.SnowmanBuild >= 4 { + rb.SnowmanPhase = snowmanPhaseBaseSmall + rb.SnowmanBuild = 0 + } + case snowmanPhaseBaseSmall: + if rb.SnowmanBuild >= 6 { + rb.SnowmanPhase = snowmanPhaseBaseMed + rb.SnowmanBuild = 0 + } + case snowmanPhaseBaseMed: + if rb.SnowmanBuild >= 8 { + rb.SnowmanPhase = snowmanPhaseBaseLarge + rb.SnowmanBuild = 0 + } + case snowmanPhaseBaseLarge: + if rb.SnowmanBuild >= 6 { + rb.SnowmanPhase = snowmanPhaseHeadDot + rb.SnowmanBuild = 0 + } + case snowmanPhaseHeadDot: + if rb.SnowmanBuild >= 5 { + rb.SnowmanPhase = snowmanPhaseHeadRound + rb.SnowmanBuild = 0 + } + case snowmanPhaseHeadRound: + if rb.SnowmanBuild >= 8 { + rb.SnowmanPhase = snowmanPhaseFace + rb.SnowmanBuild = 0 + rb.SnowmanAux = 0 + } + case snowmanPhaseFace: + rb.SnowmanAux++ + if rb.SnowmanAux >= 48 { + rb.SnowmanPhase = snowmanPhasePipe + rb.SnowmanAux = 0 + } + case snowmanPhasePipe: + rb.SnowmanAux++ + if rb.SnowmanAux >= 32 { + rb.SnowmanPhase = snowmanPhaseHat + rb.SnowmanAux = 0 + } + case snowmanPhaseHat: + // terminal + } + + if rb.Frame%140 == 0 && len(rb.SnowTrees) < 4 && rb.Height >= 5 { + x := rand.Intn(rb.Width) + if rb.snowFootprintFree(x, 1) { + h := 2 + rand.Intn(2) + if rb.Height < 7 { + h = 2 + } + rb.SnowTrees = append(rb.SnowTrees, snowTree{x: x, h: h, birthFrame: rb.Frame}) + } + } + + if rb.Frame%8 == 0 { + cx, cy := rb.snowChimneyTop() + if cy >= 0 && cx >= 0 && cx < rb.Width { + rb.SnowSmoke = append(rb.SnowSmoke, snowSmoke{x: cx, y: cy, age: 0}) + } + } + aliveSmoke := rb.SnowSmoke[:0] + for _, s := range rb.SnowSmoke { + ns := s + ns.age++ + if ns.age%2 == 0 { + ns.y-- + } + if rand.Float64() < 0.35 { + ns.x += rand.Intn(3) - 1 + } + if ns.x < 0 { + ns.x = 0 + } + if ns.x >= rb.Width { + ns.x = rb.Width - 1 + } + if ns.y >= 0 && ns.age < 28 { + aliveSmoke = append(aliveSmoke, ns) + } + } + rb.SnowSmoke = aliveSmoke +} + +func (rb *RainBackground) snowFootprintFree(x, w int) bool { + left := x + right := x + w + cL, cR := rb.SnowCabinLeft-1, rb.SnowCabinLeft+snowCabinW+2 + if right > cL && left < cR { + return false + } + sL, sR := rb.SnowmanX-3, rb.SnowmanX+4 + if rb.SnowmanPhase != snowmanPhaseNone && right > sL && left < sR { + return false + } + return true +} + +func snowGroundGlyph(depth int) string { + switch { + case depth < 1: + return " " + case depth < 6: + return "·" + case depth < 18: + return "░" + case depth < 40: + return "▒" + default: + return "▓" + } +} + +func (rb *RainBackground) snowPaintCell(cells []string, x, y int, ch string, st lipgloss.Style) { + if x < 0 || x >= rb.Width || y < 0 || y >= rb.Height { + return + } + idx := y*rb.Width + x + if idx >= 0 && idx < len(cells) { + cells[idx] = st.Render(ch) + } +} + +func (rb *RainBackground) snowPaintLine(cells []string, left, y int, line string, st lipgloss.Style) { + for i, r := range line { + x := left + i + if x < 0 || x >= rb.Width { + continue + } + rb.snowPaintCell(cells, x, y, string(r), st) + } +} + +func (rb *RainBackground) paintSnowScene(cells []string) { + if rb.Width <= 0 || rb.Height <= 0 || rb.SnowGround == nil { + return + } + night := lipgloss.NewStyle().Foreground(lipgloss.Color("#1A237E")).Faint(true) + star := lipgloss.NewStyle().Foreground(lipgloss.Color("#B0BEC5")) + wood := lipgloss.NewStyle().Foreground(lipgloss.Color("#5D4037")) + woodHi := lipgloss.NewStyle().Foreground(lipgloss.Color("#6D4C41")) + win := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC80")).Bold(true) + ever := lipgloss.NewStyle().Foreground(lipgloss.Color("#1B5E20")) + everFrost := lipgloss.NewStyle().Foreground(lipgloss.Color("#C8E6C9")).Faint(true) + snowBall := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1")) + snowHi := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + coal := lipgloss.NewStyle().Foreground(lipgloss.Color("#263238")) + nose := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6F00")).Bold(true) + smokeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#90A4AE")).Faint(true) + hatSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#212121")).Bold(true) + frost := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1")).Faint(true) + + // Night sky (row 0) + for x := 0; x < rb.Width; x++ { + var ch string + if (x+rb.Frame)%6 == 0 && (x*5+rb.Frame)%13 < 3 { + ch = star.Render("·") + } else if (x+rb.Frame*2)%9 == 0 { + ch = night.Render("░") + } else { + ch = night.Render(" ") + } + cells[x] = ch + } + + // Trees + for _, tr := range rb.SnowTrees { + baseY := rb.Height - 2 + st := ever + if tr.frost >= 2 { + st = everFrost + } + for k := 0; k < tr.h; k++ { + y := baseY - k + if y < 1 || y >= rb.Height { + continue + } + ch := "|" + if k == tr.h-1 { + ch = "^" + } + rb.snowPaintCell(cells, tr.x, y, ch, st) + } + } + + // Cabin (3 rows) when there is vertical room + roofY := rb.Height - 4 + midY := rb.Height - 3 + botY := rb.Height - 2 + if roofY >= 1 && midY >= 2 && botY >= 3 { + L := rb.SnowCabinLeft + roof := " /---^ " + if rb.SnowCabinFrost >= 2 { + roof = " /~+~^ " + } + rb.snowPaintLine(cells, L, roofY, roof, woodHi) + mid := "| * * |" + midRunes := []rune(mid) + for xi := 0; xi < len(midRunes) && xi < snowCabinW; xi++ { + c := string(midRunes[xi]) + st := wood + if c == "*" { + st = win + } + rb.snowPaintCell(cells, L+xi, midY, c, st) + } + bot := "|_____|" + rb.snowPaintLine(cells, L, botY, bot, woodHi) + if rb.SnowCabinFrost >= 1 { + roofRunes := []rune(roof) + for xi := 0; xi < len(roofRunes) && xi < snowCabinW; xi++ { + xp := L + xi + if xp == L || xp == L+snowCabinW-1 { + rb.snowPaintCell(cells, xp, roofY, string(roofRunes[xi]), frost) + } + } + } + chx := rb.SnowCabinLeft + 5 + if chx < rb.Width && roofY-1 >= 0 { + rb.snowPaintCell(cells, chx, roofY-1, "█", woodHi) + } + } + + // 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)) + } + } + + // Snowman + gnd := rb.Height - 1 + headY := gnd - 1 + faceY := gnd - 2 + hatY := gnd - 3 + if rb.SnowmanPhase >= snowmanPhaseBaseDot { + baseCh := "·" + switch { + case rb.SnowmanPhase >= snowmanPhaseBaseLarge: + baseCh = "●" + case rb.SnowmanPhase >= snowmanPhaseBaseMed: + baseCh = "O" + case rb.SnowmanPhase >= snowmanPhaseBaseSmall: + baseCh = "o" + } + rb.snowPaintCell(cells, rb.SnowmanX, gnd, baseCh, snowBall) + } + if rb.SnowmanPhase >= snowmanPhaseHeadDot { + h := "·" + if rb.SnowmanPhase >= snowmanPhaseHeadRound { + h = "O" + } + if headY >= 1 { + rb.snowPaintCell(cells, rb.SnowmanX, headY, h, snowHi) + } + } + if rb.SnowmanPhase >= snowmanPhaseFace && faceY >= 1 { + rb.snowPaintCell(cells, rb.SnowmanX-1, faceY, "•", coal) + rb.snowPaintCell(cells, rb.SnowmanX+1, faceY, "•", coal) + rb.snowPaintCell(cells, rb.SnowmanX, faceY, "@", nose) + smileY := faceY + 1 + if smileY < rb.Height { + rb.snowPaintCell(cells, rb.SnowmanX, smileY, "‿", coal) + } + } + if rb.SnowmanPhase >= snowmanPhasePipe && faceY >= 1 { + rb.snowPaintCell(cells, rb.SnowmanX+2, faceY, "╾", woodHi) + } + if rb.SnowmanPhase >= snowmanPhaseHat && hatY >= 1 { + rb.snowPaintCell(cells, rb.SnowmanX-1, hatY, "▄", hatSt) + rb.snowPaintCell(cells, rb.SnowmanX, hatY, "▀", hatSt) + rb.snowPaintCell(cells, rb.SnowmanX+1, hatY, "▄", hatSt) + } + + for _, sm := range rb.SnowSmoke { + if sm.y >= 0 && sm.y < rb.Height { + rb.snowPaintCell(cells, sm.x, sm.y, "░", smokeSt) + } + } +} diff --git a/internal/ui/view_layout_test.go b/internal/ui/view_layout_test.go index b05296e..c06b0a9 100644 --- a/internal/ui/view_layout_test.go +++ b/internal/ui/view_layout_test.go @@ -45,11 +45,11 @@ func TestMeasuredListCapacityFitsWindow_table(t *testing.T) { } cases := []struct { - name string - model RepoSelectorModel - measure func(RepoSelectorModel) int - height func(RepoSelectorModel, int) int - maxList int + name string + model RepoSelectorModel + measure func(RepoSelectorModel) int + height func(RepoSelectorModel, int) int + maxList int }{ { name: "main view", From f99cdf52cfb7f1a1d544950890d5b9c135c08beb Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 14:48:11 -0400 Subject: [PATCH 2/7] fix(ui): snow layout recursion, accumulation, and snowman clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/config/defaults.go | 6 ++ internal/config/loader.go | 1 + internal/config/snow_accum_test.go | 25 ++++++ internal/config/types.go | 26 +++++- internal/ui/config_view.go | 103 ++++++++++++++++----- internal/ui/rain_bg.go | 41 ++++++--- internal/ui/rain_bg_test.go | 3 +- internal/ui/repo_selector.go | 22 +++-- internal/ui/snow_scene.go | 139 +++++++++++++++++++---------- internal/ui/view_layout.go | 29 ++++-- 10 files changed, 297 insertions(+), 98 deletions(-) create mode 100644 internal/config/snow_accum_test.go diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 64270b5..5158574 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -43,6 +43,8 @@ func DefaultConfig() Config { StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec, RainTickMS: DefaultUIRainTickMS, ColorProfile: UIColorProfileStorm, + // SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding). + SnowAccumulationRate: 0, }, } } @@ -138,6 +140,10 @@ rain_tick_ms = 150 # 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 index 483adde..424e07c 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -111,6 +111,7 @@ func setDefaults(v *viper.Viper) { 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 index 0000000..238a969 --- /dev/null +++ b/internal/config/snow_accum_test.go @@ -0,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 index 732cc8a..4f82a56 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,7 +1,10 @@ // Package config defines the git-rain configuration schema and related constants. package config -import "strings" +import ( + "math" + "strings" +) // Config represents the complete git-rain configuration type Config struct { @@ -118,6 +121,11 @@ type UIConfig struct { // 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 ( @@ -176,3 +184,19 @@ func UIColorProfiles() []string { 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 index 7f683bc..0919757 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -64,41 +64,57 @@ var configRows = []configRow{ {label: "Custom hex palette", kind: configRowComingSoon}, } -// firstGardenVisibleRow is the visible index of the first garden-only row +// firstModeExtensionVisibleRow is where mode-specific rows are inserted // (after "Rain animation mode" and "Rain panel size"). -const firstGardenVisibleRow = 6 +const firstModeExtensionVisibleRow = 6 -// Garden settings rows appear in the menu only when rain mode is garden, -// directly under "Rain panel size" (see logicalRowIndex). +// 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 < firstGardenVisibleRow { + if visibleI < firstModeExtensionVisibleRow { return visibleI } - if visibleI < firstGardenVisibleRow+g { - return len(configRows) + (visibleI - firstGardenVisibleRow) + if visibleI < firstModeExtensionVisibleRow+g { + return len(configRows) + (visibleI - firstModeExtensionVisibleRow) } return visibleI - g } @@ -108,9 +124,10 @@ func configRowAt(visibleI int, cfg *config.Config) configRow { 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] } @@ -179,6 +196,26 @@ func gardenOffspringLabel(cfg *config.Config) string { 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 "" @@ -224,6 +261,9 @@ func configRowValue(visibleI int, cfg *config.Config) string { case 11: return "coming soon" case 12: + if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) { + return snowAccumLabel(cfg) + } return gardenGrowthPaceLabel(cfg) case 13: return gardenSeedRateLabel(cfg) @@ -284,13 +324,30 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { case 10: cfg.UI.ColorProfile = opts[idx] case 12: - switch opts[idx] { - case "calm": - cfg.UI.GardenGrowthPace = 1.32 - case "fast": - cfg.UI.GardenGrowthPace = 0.78 - default: - cfg.UI.GardenGrowthPace = 0 + 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 13: switch opts[idx] { diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index 22c2699..72d340d 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -66,7 +66,7 @@ type RainDrop struct { ColorIdx int Age int MaxAge int - Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.) + Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.) IsSeed bool // garden mode: seed vs rain } @@ -366,15 +366,16 @@ type RainBackground struct { 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 + 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 @@ -404,6 +405,18 @@ func (rb *RainBackground) SetGardenTuning(t GardenTuning) { 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++ { @@ -613,7 +626,7 @@ func (rb *RainBackground) gardenMaxFlyingSkySeeds(relief float64) int { if reliefEff > 1 { reliefEff = 1 } - ceiling := int(0.5 + float64(lo) + (float64(hi-lo))*reliefEff) + ceiling := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) if ceiling < 1 { ceiling = 1 } @@ -771,7 +784,11 @@ func (rb *RainBackground) Update() { } if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) { - rb.SnowGround[p.X]++ + add := rb.snowAccumPerLanding + if add < 1 { + add = 1 + } + rb.SnowGround[p.X] += add rb.snowNoteFlakeLand(p.X) p.Age = p.MaxAge } diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index ae4492a..625fae7 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -317,12 +317,13 @@ func TestRainBackgroundSnowRenderLineWidths(t *testing.T) { func TestSnowGroundDepthIncreases(t *testing.T) { const w, h = 24, 6 rb := NewRainBackground(w, h, config.UIRainAnimationSnow) + rb.SetSnowAccumPerLanding(4) rand.Seed(7) sum0 := 0 for _, v := range rb.SnowGround { sum0 += v } - for i := 0; i < 400; i++ { + for i := 0; i < 120; i++ { rb.Update() } sum1 := 0 diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index e0f151a..49d681b 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -548,11 +548,15 @@ func cycleRepoMode(repo git.Repository) git.Repository { return repo } +// rainStripIncludedInLayoutMeasure is whether the rain canvas height must be +// counted while measuring repo list capacity and panel outer height. This must +// not call rainVisible, which depends on those measurements (stack overflow). +func (m RepoSelectorModel) rainStripIncludedInLayoutMeasure() bool { + return m.showRain && m.rainBg != nil +} + func (m RepoSelectorModel) rainVisible() bool { - if !m.showRain { - return false - } - if m.rainBg == nil { + if !m.rainStripIncludedInLayoutMeasure() { return false } mainCap := m.mainViewMeasuredRepoListCapacity() @@ -814,7 +818,8 @@ func gardenTuningFromConfig(cfg *config.Config, rainTickMS, gardenWidth int) Gar } // applyGardenTuning re-applies the model's config-derived garden tuning to -// rb; safe to call any time after rb is (re-)created. +// rb; safe to call any time after rb is (re-)created. Snow mode also reads +// snow accumulation from config here. func (m RepoSelectorModel) applyGardenTuning(rb *RainBackground) { if rb == nil { return @@ -824,6 +829,13 @@ func (m RepoSelectorModel) applyGardenTuning(rb *RainBackground) { tick = m.cfg.UI.RainTickMS } rb.SetGardenTuning(gardenTuningFromConfig(m.cfg, tick, rb.Width)) + if rb.Mode == config.UIRainAnimationSnow { + rate := 0.0 + if m.cfg != nil { + rate = m.cfg.UI.SnowAccumulationRate + } + rb.SetSnowAccumPerLanding(config.SnowAccumPerLanding(rate)) + } } // clampCellWidth keeps one screen row within maxCells using lipgloss truncation. diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go index 53b81a4..43de63a 100644 --- a/internal/ui/snow_scene.go +++ b/internal/ui/snow_scene.go @@ -66,6 +66,38 @@ func (rb *RainBackground) initSnowScene() { rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3) } } + rb.snowSpawnInitialTrees() +} + +// snowSpawnInitialTrees places evergreen trees immediately using width-scaled +// slots (stable across runs for a given canvas width). +func (rb *RainBackground) snowSpawnInitialTrees() { + if rb.Width < 10 || rb.Height < 5 { + return + } + slots := []int{ + 2, + max(2, rb.Width/5), + max(2, rb.Width*2/7), + rb.Width - 3, + } + if rb.Width > 30 { + slots = append(slots, max(2, rb.Width/2-8), min(rb.Width-3, rb.Width/2+8)) + } + for i, x := range slots { + if x < 1 || x >= rb.Width-1 { + continue + } + if !rb.snowFootprintFree(x, 1) { + continue + } + h := 2 + if rb.Height >= 8 && i%2 == 0 { + h = 3 + } + frost := 1 + (i % 2) + rb.SnowTrees = append(rb.SnowTrees, snowTree{x: x, h: h, frost: frost, birthFrame: i * 19}) + } } func (rb *RainBackground) snowChimneyTop() (int, int) { @@ -183,17 +215,6 @@ func (rb *RainBackground) snowAdvanceScene() { // terminal } - if rb.Frame%140 == 0 && len(rb.SnowTrees) < 4 && rb.Height >= 5 { - x := rand.Intn(rb.Width) - if rb.snowFootprintFree(x, 1) { - h := 2 + rand.Intn(2) - if rb.Height < 7 { - h = 2 - } - rb.SnowTrees = append(rb.SnowTrees, snowTree{x: x, h: h, birthFrame: rb.Frame}) - } - } - if rb.Frame%8 == 0 { cx, cy := rb.snowChimneyTop() if cy >= 0 && cx >= 0 && cx < rb.Width { @@ -231,6 +252,9 @@ func (rb *RainBackground) snowFootprintFree(x, w int) bool { return false } sL, sR := rb.SnowmanX-3, rb.SnowmanX+4 + if rb.SnowmanPhase >= snowmanPhaseBaseLarge { + sL, sR = rb.SnowmanX-5, rb.SnowmanX+5 + } if rb.SnowmanPhase != snowmanPhaseNone && right > sL && left < sR { return false } @@ -283,10 +307,12 @@ func (rb *RainBackground) paintSnowScene(cells []string) { win := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC80")).Bold(true) ever := lipgloss.NewStyle().Foreground(lipgloss.Color("#1B5E20")) everFrost := lipgloss.NewStyle().Foreground(lipgloss.Color("#C8E6C9")).Faint(true) - snowBall := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1")) - snowHi := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - coal := lipgloss.NewStyle().Foreground(lipgloss.Color("#263238")) - nose := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6F00")).Bold(true) + // Snowman: high-contrast whites + bold so the figure reads above the ground bank. + snowBall := lipgloss.NewStyle().Foreground(lipgloss.Color("#FAFAFA")).Bold(true) + snowHi := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + snowParen := lipgloss.NewStyle().Foreground(lipgloss.Color("#90A4AE")).Bold(true) + coal := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F")).Bold(true) + nose := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9100")).Bold(true) smokeSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#90A4AE")).Faint(true) hatSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#212121")).Bold(true) frost := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1")).Faint(true) @@ -304,7 +330,7 @@ func (rb *RainBackground) paintSnowScene(cells []string) { cells[x] = ch } - // Trees + // Trees (evergreens: full silhouette from frame one) for _, tr := range rb.SnowTrees { baseY := rb.Height - 2 st := ever @@ -316,11 +342,15 @@ func (rb *RainBackground) paintSnowScene(cells []string) { if y < 1 || y >= rb.Height { continue } - ch := "|" if k == tr.h-1 { - ch = "^" + rb.snowPaintLine(cells, tr.x-1, y, "/\\", st) + continue } - rb.snowPaintCell(cells, tr.x, y, ch, st) + if k == tr.h-2 && tr.h >= 3 { + rb.snowPaintLine(cells, tr.x-1, y, "/█\\", st) + continue + } + rb.snowPaintCell(cells, tr.x, y, "┃", st) } } @@ -380,48 +410,63 @@ func (rb *RainBackground) paintSnowScene(cells []string) { } } - // Snowman - gnd := rb.Height - 1 - headY := gnd - 1 - faceY := gnd - 2 - hatY := gnd - 3 + // Snowman — feet sit one row above the ground snow bank (row h-2 vs pile h-1) + // so the figure reads clearly and is not merged into the depth glyphs. + feetY := rb.Height - 2 + bellyY := feetY - 1 + faceY := feetY - 2 + scarfY := feetY - 3 + hatBrimY := feetY - 4 + hatTopY := feetY - 5 + scarfSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#E53935")).Bold(true) + if rb.SnowmanPhase >= snowmanPhaseBaseDot { - baseCh := "·" switch { case rb.SnowmanPhase >= snowmanPhaseBaseLarge: - baseCh = "●" + rb.snowPaintCell(cells, rb.SnowmanX-1, feetY, "(", snowParen) + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "●", snowBall) + rb.snowPaintCell(cells, rb.SnowmanX+1, feetY, ")", snowParen) case rb.SnowmanPhase >= snowmanPhaseBaseMed: - baseCh = "O" + rb.snowPaintCell(cells, rb.SnowmanX-1, feetY, "(", snowParen) + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "○", snowBall) + rb.snowPaintCell(cells, rb.SnowmanX+1, feetY, ")", snowParen) case rb.SnowmanPhase >= snowmanPhaseBaseSmall: - baseCh = "o" + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "○", snowBall) + default: + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "·", snowHi) } - rb.snowPaintCell(cells, rb.SnowmanX, gnd, baseCh, snowBall) } - if rb.SnowmanPhase >= snowmanPhaseHeadDot { - h := "·" + if rb.SnowmanPhase >= snowmanPhaseHeadDot && bellyY >= 1 { if rb.SnowmanPhase >= snowmanPhaseHeadRound { - h = "O" - } - if headY >= 1 { - rb.snowPaintCell(cells, rb.SnowmanX, headY, h, snowHi) + rb.snowPaintCell(cells, rb.SnowmanX, bellyY, "●", snowHi) + } else { + rb.snowPaintCell(cells, rb.SnowmanX, bellyY, "•", snowBall) } } if rb.SnowmanPhase >= snowmanPhaseFace && faceY >= 1 { - rb.snowPaintCell(cells, rb.SnowmanX-1, faceY, "•", coal) - rb.snowPaintCell(cells, rb.SnowmanX+1, faceY, "•", coal) - rb.snowPaintCell(cells, rb.SnowmanX, faceY, "@", nose) - smileY := faceY + 1 - if smileY < rb.Height { - rb.snowPaintCell(cells, rb.SnowmanX, smileY, "‿", coal) - } + rb.snowPaintCell(cells, rb.SnowmanX-1, faceY, "●", coal) + rb.snowPaintCell(cells, rb.SnowmanX+1, faceY, "●", coal) + rb.snowPaintCell(cells, rb.SnowmanX, faceY, "▲", nose) } if rb.SnowmanPhase >= snowmanPhasePipe && faceY >= 1 { - rb.snowPaintCell(cells, rb.SnowmanX+2, faceY, "╾", woodHi) + rb.snowPaintCell(cells, rb.SnowmanX-2, faceY, "╴", woodHi) + rb.snowPaintCell(cells, rb.SnowmanX+2, faceY, "╶", woodHi) + } + if rb.SnowmanPhase >= snowmanPhasePipe && bellyY >= 1 { + rb.snowPaintCell(cells, rb.SnowmanX-2, bellyY, "╱", snowBall) + rb.snowPaintCell(cells, rb.SnowmanX+2, bellyY, "╲", snowBall) } - if rb.SnowmanPhase >= snowmanPhaseHat && hatY >= 1 { - rb.snowPaintCell(cells, rb.SnowmanX-1, hatY, "▄", hatSt) - rb.snowPaintCell(cells, rb.SnowmanX, hatY, "▀", hatSt) - rb.snowPaintCell(cells, rb.SnowmanX+1, hatY, "▄", hatSt) + 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"))) + } } for _, sm := range rb.SnowSmoke { diff --git a/internal/ui/view_layout.go b/internal/ui/view_layout.go index a46d351..e0c6f89 100644 --- a/internal/ui/view_layout.go +++ b/internal/ui/view_layout.go @@ -8,12 +8,14 @@ import ( "github.com/charmbracelet/lipgloss" ) -// mainViewHeaderBlock returns inner content before the repo list (rain, title, quote, waiting line). -func (m RepoSelectorModel) mainViewHeaderBlock() string { +// mainViewHeaderBlockWithRainGate returns inner content before the repo list. +// includeRainStrip controls the rain canvas; layout measurement must pass +// rainStripIncludedInLayoutMeasure(), not rainVisible(), to avoid recursion. +func (m RepoSelectorModel) mainViewHeaderBlockWithRainGate(includeRainStrip bool) string { cw := m.contentWidth() rainW := RainDisplayWidth(m.windowWidth) var s strings.Builder - if m.rainVisible() { + if includeRainStrip { s.WriteString(m.rainBg.Render()) s.WriteString("\n") s.WriteString(m.renderRainWaveStrip(rainW)) @@ -42,6 +44,11 @@ func (m RepoSelectorModel) mainViewHeaderBlock() string { return s.String() } +// mainViewHeaderBlock returns inner content before the repo list (rain, title, quote, waiting line). +func (m RepoSelectorModel) mainViewHeaderBlock() string { + return m.mainViewHeaderBlockWithRainGate(m.rainVisible()) +} + // mainViewFooterBlock returns help text plus optional scan panel (same as View tail). func (m RepoSelectorModel) mainViewFooterBlock() string { cw := m.contentWidth() @@ -178,7 +185,7 @@ func (m RepoSelectorModel) mainViewRepoListBlock(capacity int) string { // when the repo list uses the given scroll *capacity* (see clampScroll visible). func (m RepoSelectorModel) mainViewPanelOuterHeight(capacity int) int { innerW := PanelBlockWidth(m.windowWidth) - body := m.mainViewHeaderBlock() + m.mainViewRepoListBlock(capacity) + m.mainViewFooterBlock() + body := m.mainViewHeaderBlockWithRainGate(m.rainStripIncludedInLayoutMeasure()) + m.mainViewRepoListBlock(capacity) + m.mainViewFooterBlock() return lipgloss.Height(renderMainPanelBox(innerW, body)) } @@ -194,7 +201,7 @@ func (m RepoSelectorModel) mainViewMeasuredRepoListCapacity() int { return 1 } innerW := PanelBlockWidth(m.windowWidth) - header := m.mainViewHeaderBlock() + header := m.mainViewHeaderBlockWithRainGate(m.rainStripIncludedInLayoutMeasure()) footer := m.mainViewFooterBlock() outerHeight := func(capacity int) int { body := header + m.mainViewRepoListBlock(capacity) + footer @@ -221,10 +228,10 @@ func (m RepoSelectorModel) mainViewMeasuredRepoListCapacity() int { // --- Ignored repositories view (same height measurement as main view) --- -func (m RepoSelectorModel) ignoredViewHeaderBlock() string { +func (m RepoSelectorModel) ignoredViewHeaderBlockWithRainGate(includeRainStrip bool) string { rainW := RainDisplayWidth(m.windowWidth) var s strings.Builder - if m.rainVisible() { + if includeRainStrip { s.WriteString(m.rainBg.Render()) s.WriteString("\n") s.WriteString(m.renderRainWaveStrip(rainW)) @@ -239,6 +246,10 @@ func (m RepoSelectorModel) ignoredViewHeaderBlock() string { return s.String() } +func (m RepoSelectorModel) ignoredViewHeaderBlock() string { + return m.ignoredViewHeaderBlockWithRainGate(m.rainVisible()) +} + func (m RepoSelectorModel) ignoredViewFooterBlock() string { return renderIgnoredViewHelp(m.contentWidth()) } @@ -321,7 +332,7 @@ func (m RepoSelectorModel) ignoredViewListBlock(capacity int) string { func (m RepoSelectorModel) ignoredViewPanelOuterHeight(capacity int) int { innerW := PanelBlockWidth(m.windowWidth) - body := m.ignoredViewHeaderBlock() + m.ignoredViewListBlock(capacity) + m.ignoredViewFooterBlock() + body := m.ignoredViewHeaderBlockWithRainGate(m.rainStripIncludedInLayoutMeasure()) + m.ignoredViewListBlock(capacity) + m.ignoredViewFooterBlock() return lipgloss.Height(renderMainPanelBox(innerW, body)) } @@ -334,7 +345,7 @@ func (m RepoSelectorModel) ignoredMeasuredListCapacity() int { return 1 } innerW := PanelBlockWidth(m.windowWidth) - header := m.ignoredViewHeaderBlock() + header := m.ignoredViewHeaderBlockWithRainGate(m.rainStripIncludedInLayoutMeasure()) footer := m.ignoredViewFooterBlock() outerHeight := func(capacity int) int { body := header + m.ignoredViewListBlock(capacity) + footer From aa87bd4c06cfbc4efdbf7f50951674ee4d3d7c81 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 14:54:51 -0400 Subject: [PATCH 3/7] test(ui): drop deprecated rand.Seed in snow rain tests Fixes golangci-lint staticcheck SA1019 in CI (Go 1.20+). --- internal/ui/rain_bg_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 625fae7..333675e 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -1,7 +1,6 @@ package ui import ( - "math/rand" "strings" "testing" @@ -298,7 +297,6 @@ func TestRenderRainWaveSnowWidth(t *testing.T) { func TestRainBackgroundSnowRenderLineWidths(t *testing.T) { const w, h = 32, 8 rb := NewRainBackground(w, h, config.UIRainAnimationSnow) - rand.Seed(42) for i := 0; i < 50; i++ { rb.Update() } @@ -318,7 +316,6 @@ func TestSnowGroundDepthIncreases(t *testing.T) { const w, h = 24, 6 rb := NewRainBackground(w, h, config.UIRainAnimationSnow) rb.SetSnowAccumPerLanding(4) - rand.Seed(7) sum0 := 0 for _, v := range rb.SnowGround { sum0 += v @@ -338,7 +335,6 @@ func TestSnowGroundDepthIncreases(t *testing.T) { func TestSnowmanProgressesWithLandings(t *testing.T) { const w, h = 40, 8 rb := NewRainBackground(w, h, config.UIRainAnimationSnow) - rand.Seed(1) rb.SnowmanPhase = snowmanPhaseBaseDot rb.SnowmanBuild = 0 for i := 0; i < 30; i++ { From ad6153eefbe708641312bab7fdea01522c9565a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 19:02:05 +0000 Subject: [PATCH 4/7] fix(ui): paint snow scene lines by rune column, not byte offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/ui/snow_scene.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go index 43de63a..7e743f0 100644 --- a/internal/ui/snow_scene.go +++ b/internal/ui/snow_scene.go @@ -287,12 +287,13 @@ func (rb *RainBackground) snowPaintCell(cells []string, x, y int, ch string, st } func (rb *RainBackground) snowPaintLine(cells []string, left, y int, line string, st lipgloss.Style) { - for i, r := range line { - x := left + i - if x < 0 || x >= rb.Width { - continue + col := 0 + for _, r := range line { + x := left + col + if x >= 0 && x < rb.Width { + rb.snowPaintCell(cells, x, y, string(r), st) } - rb.snowPaintCell(cells, x, y, string(r), st) + col++ } } From 3724762de34bfc079d9f71296824f2dae3b35c8d Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 15:04:47 -0400 Subject: [PATCH 5/7] feat(ui): scatter snow trees by size with random placement 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. --- internal/ui/rain_bg_test.go | 37 +++++++++++++++++++ internal/ui/snow_scene.go | 74 +++++++++++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 333675e..fa655e2 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -332,6 +332,43 @@ func TestSnowGroundDepthIncreases(t *testing.T) { } } +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) { + // Wide enough that at least one 3-column canopy clears the cabin footprint. + rb := NewRainBackground(16, 8, config.UIRainAnimationSnow) + if len(rb.SnowTrees) < 1 { + t.Fatalf("expected at least 1 snow tree, got %d", len(rb.SnowTrees)) + } +} + func TestSnowmanProgressesWithLandings(t *testing.T) { const w, h = 40, 8 rb := NewRainBackground(w, h, config.UIRainAnimationSnow) diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go index 7e743f0..49e37ec 100644 --- a/internal/ui/snow_scene.go +++ b/internal/ui/snow_scene.go @@ -69,35 +69,79 @@ func (rb *RainBackground) initSnowScene() { rb.snowSpawnInitialTrees() } -// snowSpawnInitialTrees places evergreen trees immediately using width-scaled -// slots (stable across runs for a given canvas width). +// snowTreeSiteFree reports whether a trunk at column x can sit without clipping +// the 3-cell canopy (/\, /█\, or trunk) and without overlapping the cabin or +// snowman reservation. +func (rb *RainBackground) snowTreeSiteFree(x int) bool { + if x < 1 || x >= rb.Width-1 { + return false + } + return rb.snowFootprintFree(x-1, 3) +} + +// snowSpawnInitialTrees places scattered evergreens with random trunk columns. +// Target count and minimum spacing scale with width and height; each new +// RainBackground (e.g. resize) picks a fresh layout. func (rb *RainBackground) snowSpawnInitialTrees() { if rb.Width < 10 || rb.Height < 5 { return } - slots := []int{ - 2, - max(2, rb.Width/5), - max(2, rb.Width*2/7), - rb.Width - 3, + want := rb.Width/9 + rb.Height/3 + if want < 2 { + want = 2 } - if rb.Width > 30 { - slots = append(slots, max(2, rb.Width/2-8), min(rb.Width-3, rb.Width/2+8)) + if want > 14 { + want = 14 } - for i, x := range slots { - if x < 1 || x >= rb.Width-1 { + minGap := rb.Width / 12 + if minGap < 4 { + minGap = 4 + } + if minGap > 10 { + minGap = 10 + } + + const maxAttempts = 500 + trees := make([]snowTree, 0, want) + + for attempt := 0; attempt < maxAttempts && len(trees) < want; attempt++ { + x := 1 + rand.Intn(rb.Width-2) + if !rb.snowTreeSiteFree(x) { continue } - if !rb.snowFootprintFree(x, 1) { + tooClose := false + for _, tr := range trees { + if absInt(tr.x-x) < minGap { + tooClose = true + break + } + } + if tooClose { continue } h := 2 - if rb.Height >= 8 && i%2 == 0 { + if rb.Height >= 9 && rand.Intn(100) < 55 { h = 3 } - frost := 1 + (i % 2) - rb.SnowTrees = append(rb.SnowTrees, snowTree{x: x, h: h, frost: frost, birthFrame: i * 19}) + frost := rand.Intn(3) + birth := rand.Intn(67) + trees = append(trees, snowTree{x: x, h: h, frost: frost, birthFrame: birth}) + } + + if len(trees) == 0 { + for x := 1; x < rb.Width-1; x++ { + if rb.snowTreeSiteFree(x) { + h := 2 + if rb.Height >= 9 { + h = 3 + } + trees = append(trees, snowTree{x: x, h: h, frost: 0, birthFrame: 0}) + break + } + } } + + rb.SnowTrees = append(rb.SnowTrees[:0], trees...) } func (rb *RainBackground) snowChimneyTop() (int, int) { From f5360afec5fe24cf26779199e97289f21fe9786e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 19:14:08 +0000 Subject: [PATCH 6/7] fix(ui): center snowman hat/scarf; show all snow accumulation rates Co-authored-by: Ben Schellenberger --- internal/ui/config_view.go | 22 +++++++--------------- internal/ui/snow_scene.go | 4 ++-- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index 0919757..db1341d 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -77,7 +77,7 @@ var gardenSettingsConfigRows = []configRow{ // 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×"}}, + {label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "5×", "6×", "7×", "8×"}}, } func modeExtensionRows(cfg *config.Config) []configRow { @@ -200,20 +200,8 @@ 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×" - } + n := config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) + return fmt.Sprintf("%d×", n) } func configRowValue(visibleI int, cfg *config.Config) string { @@ -332,8 +320,12 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { 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: diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go index 49e37ec..9c1770f 100644 --- a/internal/ui/snow_scene.go +++ b/internal/ui/snow_scene.go @@ -502,10 +502,10 @@ func (rb *RainBackground) paintSnowScene(cells []string) { rb.snowPaintCell(cells, rb.SnowmanX+2, bellyY, "╲", snowBall) } if rb.SnowmanPhase >= snowmanPhaseHat && scarfY >= 1 { - rb.snowPaintLine(cells, rb.SnowmanX-2, scarfY, "≋≋≋", scarfSt) + rb.snowPaintLine(cells, rb.SnowmanX-1, scarfY, "≋≋≋", scarfSt) } if rb.SnowmanPhase >= snowmanPhaseHat && hatBrimY >= 1 { - rb.snowPaintLine(cells, rb.SnowmanX-2, hatBrimY, "───", hatSt) + rb.snowPaintLine(cells, rb.SnowmanX-1, hatBrimY, "───", hatSt) } if rb.SnowmanPhase >= snowmanPhaseHat && hatTopY >= 1 { rb.snowPaintCell(cells, rb.SnowmanX, hatTopY, "█", hatSt) From 639adee6925e6088582960bbcf035a9427226b15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 19:25:24 +0000 Subject: [PATCH 7/7] fix(ui): apply snow tuning at stream startup; reserve snowman for trees Co-authored-by: Ben Schellenberger --- internal/ui/repo_selector.go | 5 ++--- internal/ui/snow_scene.go | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 49d681b..66a67f9 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -288,9 +288,8 @@ func NewRepoSelectorModelStream( quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0, } rainH := base.clampedRainBackgroundHeight() - rainBg := NewRainBackground(bgW, rainH, animMode) - rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) - base.rainBg = rainBg + base.rainBg = NewRainBackground(bgW, rainH, animMode) + base.applyGardenTuning(base.rainBg) return base } diff --git a/internal/ui/snow_scene.go b/internal/ui/snow_scene.go index 9c1770f..a663a58 100644 --- a/internal/ui/snow_scene.go +++ b/internal/ui/snow_scene.go @@ -299,7 +299,11 @@ func (rb *RainBackground) snowFootprintFree(x, w int) bool { if rb.SnowmanPhase >= snowmanPhaseBaseLarge { sL, sR = rb.SnowmanX-5, rb.SnowmanX+5 } - if rb.SnowmanPhase != snowmanPhaseNone && right > sL && left < sR { + excludeSnowman := rb.SnowmanPhase != snowmanPhaseNone + if rb.SnowmanPhase == snowmanPhaseNone && rb.Width >= 18 { + excludeSnowman = true + } + if excludeSnowman && right > sL && left < sR { return false } return true