Skip to content
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
15 changes: 13 additions & 2 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions internal/config/snow_accum_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
65 changes: 63 additions & 2 deletions internal/config/types.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand Down Expand Up @@ -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"`

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