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..5158574 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -37,11 +37,14 @@ func DefaultConfig() Config { 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 @@ 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 @@ -133,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 fe89ec5..424e07c 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) @@ -108,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 43c4a3b..4f82a56 100644 --- 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 @@ 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"` @@ -111,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 ( @@ -127,8 +142,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{ @@ -139,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 f9266be..db1341d 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,37 +64,57 @@ var configRows = []configRow{ {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 + } + switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) { + case config.UIRainAnimationGarden: + return gardenSettingsConfigRows + case config.UIRainAnimationSnow: + return snowSettingsConfigRows + default: + return nil } - return 0 +} + +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 @@ 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] } @@ -169,6 +196,14 @@ func gardenOffspringLabel(cfg *config.Config) string { 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 @@ 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) + if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) { + return snowAccumLabel(cfg) + } + return gardenGrowthPaceLabel(cfg) case 13: + return gardenSeedRateLabel(cfg) + case 14: return gardenOffspringLabel(cfg) } return "" @@ -234,7 +274,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,27 +298,50 @@ 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: - 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 13: switch opts[idx] { case "rare": cfg.UI.GardenSeedRate = 0.06 @@ -287,7 +350,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 +560,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..72d340d 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -364,6 +364,18 @@ 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 + snowAccumPerLanding int // ground depth per landed flake (1..8) } // NewRainBackground creates a new rain background @@ -393,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++ { @@ -412,9 +436,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 +474,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, @@ -663,9 +694,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 +782,16 @@ 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) { + 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 @@ 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 +824,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 +861,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 +1311,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 +1386,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 +1435,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..fa655e2 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -285,3 +285,100 @@ 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) + 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) { + // 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) + 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..66a67f9 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,10 @@ func NewRepoSelectorModelStream( startupQuoteVisible: showStartupQuote, quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0, } + rainH := base.clampedRainBackgroundHeight() + base.rainBg = NewRainBackground(bgW, rainH, animMode) + base.applyGardenTuning(base.rainBg) + return base } func (m RepoSelectorModel) Init() tea.Cmd { @@ -338,7 +342,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)) @@ -542,12 +547,26 @@ 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 { + if !m.rainStripIncludedInLayoutMeasure() { + return false + } + mainCap := m.mainViewMeasuredRepoListCapacity() + if m.mainViewPanelOuterHeight(mainCap) > m.windowHeight || mainCap < 1 { return false } - // Need at least enough rows: rain bg (5) + wave (1) + blank (1) + title (1) + list (1) = 9 - return m.windowHeight >= 9 + igCap := m.ignoredMeasuredListCapacity() + if m.ignoredViewPanelOuterHeight(igCap) > m.windowHeight || igCap < 1 { + return false + } + return true } func (m RepoSelectorModel) quoteVisible() bool { @@ -715,6 +734,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) @@ -761,7 +817,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 @@ -771,6 +828,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 new file mode 100644 index 0000000..a663a58 --- /dev/null +++ b/internal/ui/snow_scene.go @@ -0,0 +1,526 @@ +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) + } + } + rb.snowSpawnInitialTrees() +} + +// 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 + } + want := rb.Width/9 + rb.Height/3 + if want < 2 { + want = 2 + } + if want > 14 { + want = 14 + } + 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 + } + tooClose := false + for _, tr := range trees { + if absInt(tr.x-x) < minGap { + tooClose = true + break + } + } + if tooClose { + continue + } + h := 2 + if rb.Height >= 9 && rand.Intn(100) < 55 { + h = 3 + } + 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) { + 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%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 >= snowmanPhaseBaseLarge { + sL, sR = rb.SnowmanX-5, rb.SnowmanX+5 + } + excludeSnowman := rb.SnowmanPhase != snowmanPhaseNone + if rb.SnowmanPhase == snowmanPhaseNone && rb.Width >= 18 { + excludeSnowman = true + } + if excludeSnowman && 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) { + col := 0 + for _, r := range line { + x := left + col + if x >= 0 && x < rb.Width { + rb.snowPaintCell(cells, x, y, string(r), st) + } + col++ + } +} + +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) + // 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) + + // 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 (evergreens: full silhouette from frame one) + 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 + } + if k == tr.h-1 { + rb.snowPaintLine(cells, tr.x-1, y, "/\\", st) + continue + } + if k == tr.h-2 && tr.h >= 3 { + rb.snowPaintLine(cells, tr.x-1, y, "/█\\", st) + continue + } + rb.snowPaintCell(cells, tr.x, y, "┃", 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 — 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 { + switch { + case rb.SnowmanPhase >= snowmanPhaseBaseLarge: + 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: + 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: + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "○", snowBall) + default: + rb.snowPaintCell(cells, rb.SnowmanX, feetY, "·", snowHi) + } + } + if rb.SnowmanPhase >= snowmanPhaseHeadDot && bellyY >= 1 { + if rb.SnowmanPhase >= snowmanPhaseHeadRound { + 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) + } + if rb.SnowmanPhase >= snowmanPhasePipe && faceY >= 1 { + 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 && scarfY >= 1 { + rb.snowPaintLine(cells, rb.SnowmanX-1, scarfY, "≋≋≋", scarfSt) + } + if rb.SnowmanPhase >= snowmanPhaseHat && hatBrimY >= 1 { + rb.snowPaintLine(cells, rb.SnowmanX-1, 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 { + if sm.y >= 0 && sm.y < rb.Height { + rb.snowPaintCell(cells, sm.x, sm.y, "░", smokeSt) + } + } +} 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 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",