diff --git a/README.md b/README.md index 8bc9d60..98bfe48 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,36 @@ GIT_RAIN_GLOBAL_RISKY_MODE=true git-rain GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain ``` +### Garden mode tuning (advanced) + +Picking `rain_animation_mode = "garden"` swaps the rain background for a +slow-paced lifecycle: seeds drift down with the rain, plants progress through +sprout, bud, and bloom, then wither and scatter 2–3 new seeds nearby. When the +visible plants cover roughly 80% of the rain strip, the storm clears, the sun +comes out, and the surviving flowers stay forever. + +The defaults aim for a calm pace, but a few advanced TOML keys are available +under `[ui]` for tweaking. They are intentionally **not** surfaced in the +in-app settings TUI — leave any unset (or set to `0`) to keep the default. + +```toml +[ui] +rain_animation_mode = "garden" + +# garden_seed_rate = 0.055 # fraction of new sky drops that fall as seeds (0..1) +# garden_growth_pace = 1.0 # multiplier on stage moisture thresholds (>1 = slower) +# garden_bloom_duration_base = 60 # min frames a flower lingers in full bloom +# garden_bloom_duration_jitter = 40 # extra random frames added to bloom lifetime +# garden_wither_duration = 28 # frames a withered plant lingers before re-seeding +# garden_offspring_min = 2 # minimum seeds a dying plant scatters +# garden_offspring_max = 3 # maximum seeds a dying plant scatters +# garden_offspring_spread = 3 # X-jitter half-width around the parent column +``` + +`garden_growth_pace` is the most useful single dial: set it to `2.0` to roughly +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. + ### 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/defaults.go b/internal/config/defaults.go index fd3a6a0..cd3c569 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -114,7 +114,8 @@ mainline_patterns = [] show_rain_animation = true # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), -# or "matrix" (falling code characters) +# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow, +# then the rain stops and the sun comes out) rain_animation_mode = "basic" # Show flavor quotes in the TUI banner @@ -131,5 +132,19 @@ rain_tick_ms = 150 # Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave" color_profile = "storm" + +# --- 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 +# offspring more (or less) prolific. +# +# garden_seed_rate = 0.055 # sky seed density (0..1); runtime caps bursts per frame +# garden_growth_pace = 1.0 # multiplier on stage moisture thresholds (>1 = slower) +# garden_bloom_duration_base = 60 # min frames a flower lingers in full bloom +# garden_bloom_duration_jitter = 40 # extra random frames added to bloom lifetime +# garden_wither_duration = 28 # frames a withered plant lingers before re-seeding +# garden_offspring_min = 1 # minimum seeds a dying plant scatters +# garden_offspring_max = 2 # maximum seeds a dying plant scatters +# garden_offspring_spread = 2 # X-jitter half-width around the parent column ` } diff --git a/internal/config/loader.go b/internal/config/loader.go index 4d8b3aa..fe89ec5 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -97,6 +97,17 @@ func setDefaults(v *viper.Viper) { v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec) v.SetDefault("ui.rain_tick_ms", defaults.UI.RainTickMS) v.SetDefault("ui.color_profile", defaults.UI.ColorProfile) + + // Garden tuning keys default to zero so the runtime can detect "unset" + // and substitute the built-in defaults from DefaultGardenTuning(). + v.SetDefault("ui.garden_seed_rate", defaults.UI.GardenSeedRate) + v.SetDefault("ui.garden_growth_pace", defaults.UI.GardenGrowthPace) + v.SetDefault("ui.garden_bloom_duration_base", defaults.UI.GardenBloomDurationBase) + v.SetDefault("ui.garden_bloom_duration_jitter", defaults.UI.GardenBloomDurationJitter) + v.SetDefault("ui.garden_wither_duration", defaults.UI.GardenWitherDuration) + 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) } // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on diff --git a/internal/config/types.go b/internal/config/types.go index ea5d6c3..43c4a3b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -57,7 +57,8 @@ type UIConfig struct { ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"` // Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), - // or "matrix" (falling code glyphs in the same column pattern). + // "matrix" (falling code glyphs in the same column pattern), or "garden" + // (seeds, rain, growth, then sun). RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"` // Show flavor quotes: TUI banner plus CLI motivation lines. @@ -76,6 +77,40 @@ type UIConfig struct { // Color profile for rain and TUI accents. // Options: "storm", "drizzle", "monsoon", "rainbow", "synthwave". ColorProfile string `mapstructure:"color_profile" toml:"color_profile"` + + // Garden mode tuning (used when rain_animation_mode = "garden"). + // In the settings TUI, three rows appear under Rain animation mode; any + // field left at zero here still falls back to built-in defaults at runtime. + + // GardenSeedRate is the target probability (0..1) for sky seeds; the + // simulator caps seeds per frame, so this reads as density, not i.i.d. + // rolls. Lower = rarer; default ~0.055. + GardenSeedRate float64 `mapstructure:"garden_seed_rate" toml:"garden_seed_rate,omitempty"` + + // GardenGrowthPace multiplies the moisture thresholds for every growth + // stage. >1 slows growth; <1 speeds it up; 0 = use defaults. + GardenGrowthPace float64 `mapstructure:"garden_growth_pace" toml:"garden_growth_pace,omitempty"` + + // GardenBloomDurationBase is the minimum bloom lifetime in animation + // frames before a flower starts to wither. 0 = default. + GardenBloomDurationBase int `mapstructure:"garden_bloom_duration_base" toml:"garden_bloom_duration_base,omitempty"` + + // GardenBloomDurationJitter is added on top of the base bloom duration + // (uniform random in [0, jitter)). 0 = default. + GardenBloomDurationJitter int `mapstructure:"garden_bloom_duration_jitter" toml:"garden_bloom_duration_jitter,omitempty"` + + // GardenWitherDuration is how many frames a withered plant lingers + // before crumbling and seeding offspring. 0 = default. + GardenWitherDuration int `mapstructure:"garden_wither_duration" toml:"garden_wither_duration,omitempty"` + + // GardenOffspringMin/Max bound how many seeds a dying plant scatters. + // 0 = use defaults (currently 1 and 2). + GardenOffspringMin int `mapstructure:"garden_offspring_min" toml:"garden_offspring_min,omitempty"` + GardenOffspringMax int `mapstructure:"garden_offspring_max" toml:"garden_offspring_max,omitempty"` + + // 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"` } const ( @@ -91,6 +126,7 @@ const ( UIRainAnimationBasic = "basic" UIRainAnimationAdvanced = "advanced" UIRainAnimationMatrix = "matrix" + UIRainAnimationGarden = "garden" ) // UIColorProfiles returns valid built-in UI color profile names. diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index a0ff448..f9266be 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -41,6 +41,7 @@ var configRows = []configRow{ config.UIRainAnimationBasic, config.UIRainAnimationAdvanced, config.UIRainAnimationMatrix, + config.UIRainAnimationGarden, }}, {label: "Show flavor quotes", kind: configRowBool}, {label: "Flavor quote behavior", kind: configRowEnum, options: []string{ @@ -57,11 +58,122 @@ var configRows = []configRow{ {label: "Custom hex palette", kind: configRowComingSoon}, } -func configRowValue(i int, cfg *config.Config) string { +// Garden settings rows appear in the menu only when rain mode is garden, +// directly under "Rain animation mode" (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"}}, + {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) + } + return 0 +} + +func visibleConfigRowCount(cfg *config.Config) int { + return len(configRows) + gardenSettingsRowCount(cfg) +} + +// logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1 +// or len(configRows)+k for garden-only rows. +func logicalRowIndex(visibleI int, cfg *config.Config) int { + g := gardenSettingsRowCount(cfg) + if g == 0 { + return visibleI + } + if visibleI < 5 { + return visibleI + } + if visibleI < 5+g { + return len(configRows) + (visibleI - 5) + } + return visibleI - g +} + +func configRowAt(visibleI int, cfg *config.Config) configRow { + li := logicalRowIndex(visibleI, cfg) + if li < len(configRows) { + return configRows[li] + } + gi := li - len(configRows) + if gi >= 0 && gi < len(gardenSettingsConfigRows) { + return gardenSettingsConfigRows[gi] + } + return configRows[len(configRows)-1] +} + +func clampConfigCursor(cfg *config.Config, cur int) int { + n := visibleConfigRowCount(cfg) + if n <= 0 { + return 0 + } + if cur >= n { + return n - 1 + } + if cur < 0 { + return 0 + } + return cur +} + +func gardenGrowthPaceLabel(cfg *config.Config) string { + if cfg == nil { + return "normal" + } + p := cfg.UI.GardenGrowthPace + if p <= 0 { + return "normal" + } + if p >= 1.2 { + return "calm" + } + if p < 0.9 { + return "fast" + } + return "normal" +} + +func gardenSeedRateLabel(cfg *config.Config) string { + if cfg == nil { + return "normal" + } + s := cfg.UI.GardenSeedRate + if s <= 0 { + return "normal" + } + if s < 0.08 { + return "rare" + } + if s > 0.12 { + return "often" + } + return "normal" +} + +func gardenOffspringLabel(cfg *config.Config) string { + if cfg == nil { + return "default" + } + if cfg.UI.GardenOffspringMin <= 0 && cfg.UI.GardenOffspringMax <= 0 { + return "default" + } + if cfg.UI.GardenOffspringMin >= 3 { + return "many" + } + if cfg.UI.GardenOffspringMax <= 2 && cfg.UI.GardenOffspringMin > 0 { + return "few" + } + return "default" +} + +func configRowValue(visibleI int, cfg *config.Config) string { if cfg == nil { return "" } - switch i { + switch logicalRowIndex(visibleI, cfg) { case 0: return cfg.Global.DefaultMode case 1: @@ -99,18 +211,25 @@ func configRowValue(i int, cfg *config.Config) string { return cfg.UI.ColorProfile case 10: return "coming soon" + case 11: + return gardenGrowthPaceLabel(cfg) + case 12: + return gardenSeedRateLabel(cfg) + case 13: + return gardenOffspringLabel(cfg) } return "" } -func applyConfigChange(i int, cfg *config.Config, dir int) { +func applyConfigChange(visibleI int, cfg *config.Config, dir int) { if cfg == nil { return } - row := configRows[i] + row := configRowAt(visibleI, cfg) + li := logicalRowIndex(visibleI, cfg) switch row.kind { case configRowBool: - switch i { + switch li { case 1: cfg.Global.DisableScan = !cfg.Global.DisableScan case 3: @@ -120,7 +239,7 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { } case configRowEnum: opts := row.options - cur := configRowValue(i, cfg) + cur := configRowValue(visibleI, cfg) idx := 0 for j, o := range opts { if o == cur { @@ -129,7 +248,7 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { } } idx = (idx + dir + len(opts)) % len(opts) - switch i { + switch li { case 0: cfg.Global.DefaultMode = opts[idx] case 2: @@ -150,6 +269,39 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { applyRainTickChange(cfg, opts, dir) case 9: 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: + switch opts[idx] { + case "rare": + cfg.UI.GardenSeedRate = 0.06 + case "often": + cfg.UI.GardenSeedRate = 0.15 + default: + cfg.UI.GardenSeedRate = 0 + } + case 13: + switch opts[idx] { + case "few": + cfg.UI.GardenOffspringMin = 1 + cfg.UI.GardenOffspringMax = 2 + cfg.UI.GardenOffspringSpread = 2 + case "many": + cfg.UI.GardenOffspringMin = 3 + cfg.UI.GardenOffspringMax = 4 + cfg.UI.GardenOffspringSpread = 5 + default: + cfg.UI.GardenOffspringMin = 0 + cfg.UI.GardenOffspringMax = 0 + cfg.UI.GardenOffspringSpread = 0 + } } case configRowComingSoon: // reserved @@ -207,12 +359,13 @@ func (m RepoSelectorModel) updateConfigView(msg tea.KeyMsg, cmds []tea.Cmd) (tea } case "down", "j": - if m.configCursor < len(configRows)-1 { + if m.configCursor < visibleConfigRowCount(m.cfg)-1 { m.configCursor++ } case " ", "right", "l": applyConfigChange(m.configCursor, m.cfg, +1) + m.configCursor = clampConfigCursor(m.cfg, m.configCursor) if m.cfg != nil { applyColorProfile(m.cfg.UI.ColorProfile) } @@ -221,6 +374,7 @@ func (m RepoSelectorModel) updateConfigView(msg tea.KeyMsg, cmds []tea.Cmd) (tea case "left", "h": applyConfigChange(m.configCursor, m.cfg, -1) + m.configCursor = clampConfigCursor(m.cfg, m.configCursor) if m.cfg != nil { applyColorProfile(m.cfg.UI.ColorProfile) } @@ -251,7 +405,7 @@ func (m RepoSelectorModel) viewConfig() string { rainW := RainDisplayWidth(m.windowWidth) s.WriteString(m.rainBg.Render()) s.WriteString("\n") - s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) + s.WriteString(m.renderRainWaveStrip(rainW)) s.WriteString("\n\n") } @@ -273,7 +427,8 @@ func (m RepoSelectorModel) viewConfig() string { valueStyle := lipgloss.NewStyle().Foreground(activeProfile().configValue).Bold(true) dimStyle := lipgloss.NewStyle().Foreground(activeProfile().configDim) - for i, row := range configRows { + for i := 0; i < visibleConfigRowCount(m.cfg); i++ { + row := configRowAt(i, m.cfg) cur := " " if m.configCursor == i { cur = ">" @@ -342,7 +497,13 @@ 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 { - m.rainBg.Mode = m.rainAnimationMode + bgW, h := m.rainBg.Width, m.rainBg.Height + if m.rainBg.Mode != m.rainAnimationMode { + m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode) + } else { + m.rainBg.Mode = m.rainAnimationMode + } + m.applyGardenTuning(m.rainBg) } m.showStartupQuote = m.cfg.UI.ShowStartupQuote m.startupQuoteBehavior = m.cfg.UI.StartupQuoteBehavior diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index eb82122..71d4ce4 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -26,6 +26,38 @@ var cloudChars = [...]string{"☁", "░", "▒", "▓", "█"} // flowerStages for advanced mode bottom row: growth over time var flowerStages = []string{"·", "♦", "✿", "❀"} +// gardenFlowerAccentHex holds distinct flower-head colors (garden mode). +var gardenFlowerAccentHex = []string{ + "#EC407A", // pink + "#AB47BC", // purple + "#FFCA28", // amber + "#FF7043", // deep orange + "#BA68C8", // lavender + "#F06292", // light pink + "#FFD54F", // golden yellow + "#7E57C2", // deep purple + "#26C6DA", // cyan + "#FFEE58", // yellow + "#EF5350", // coral + "#66BB6A", // green blossom +} + +func gardenFlowerForeground(tint int) lipgloss.Color { + n := len(gardenFlowerAccentHex) + if n == 0 { + return activeProfile().flowerColor + } + u := tint % n + if u < 0 { + u += n + } + return lipgloss.Color(gardenFlowerAccentHex[u]) +} + +func gardenFlowerStyle(tint int) lipgloss.Style { + return lipgloss.NewStyle().Foreground(gardenFlowerForeground(tint)) +} + // RainDrop represents a single falling raindrop particle type RainDrop struct { X int @@ -35,6 +67,284 @@ type RainDrop struct { Age int MaxAge int Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.) + IsSeed bool // garden mode: seed vs rain +} + +// gardenStage drives the lifecycle in garden animation mode. +const ( + gardenStageNone = iota + gardenStagePlanted + gardenStageSprout + gardenStageBud + gardenStageBloom + gardenStageWither + gardenStageEternal +) + +type gardenPlot struct { + stage int + moisture int + bloomAge int + witherAge int + maxBloom int // randomized per-bloom duration; computed when entering bloom + // flowerTint picks a stable accent from gardenFlowerAccentHex for bloom / + // eternal flower heads; set when a seed first plants the column. + flowerTint int +} + +// GardenTuning controls the pacing and reproductive behavior of garden mode. +// Zero-valued fields are filled in from DefaultGardenTuning() by ResolveGardenTuning. +type GardenTuning struct { + // SeedSpawnRate is the fraction of new sky drops that fall as seeds (0..1). + SeedSpawnRate float64 + + // RainAbsorbExtraChance is the probability that an extra rain drop in the + // same frame still counts after the per-column water cap is reached. + RainAbsorbExtraChance float64 + + // Moisture thresholds to advance through plant stages. + PlantedToSproutMoisture int + SproutToBudMoisture int + BudToBloomMoisture int + + // SeedMoistureBoost is the moisture added when a seed lands on an existing plot. + SeedMoistureBoost int + + // Bloom and wither timing (frames). Effective bloom duration is + // BloomDurationBase + rand.Intn(BloomDurationJitter). + BloomDurationBase int + BloomDurationJitter int + WitherDuration int + + // OffspringMin..OffspringMax (inclusive) seeds spawn from each dying plant. + // OffspringSpread is the half-width of the X jitter window around the parent. + OffspringMin int + OffspringMax int + OffspringSpread int + + // PresetAggression is 0..1 from the settings TUI (fast growth, often seeds, + // many offspring). Higher values ease wall-clock slowdown and relax early + // sky-seed throttling so "power" presets are not smothered by calm-default + // pacing. Set only from gardenTuningFromConfig; otherwise 0. + PresetAggression float64 +} + +// DefaultGardenTuning returns the built-in pacing constants chosen so the +// garden takes its time: most sky pixels are rain, columns advance at most +// once per frame, blooms linger, and dying plants scatter few nearby seeds. +func DefaultGardenTuning() GardenTuning { + return GardenTuning{ + SeedSpawnRate: 0.055, + RainAbsorbExtraChance: 0.11, + PlantedToSproutMoisture: 16, + SproutToBudMoisture: 22, + BudToBloomMoisture: 30, + SeedMoistureBoost: 1, + BloomDurationBase: 85, + BloomDurationJitter: 55, + WitherDuration: 34, + OffspringMin: 1, + OffspringMax: 2, + OffspringSpread: 2, + } +} + +// ResolveGardenTuning fills any zero-valued field in t with the corresponding +// default. Callers (e.g. config plumbing) can pass partial structs. +func ResolveGardenTuning(t GardenTuning) GardenTuning { + d := DefaultGardenTuning() + if t.SeedSpawnRate <= 0 { + t.SeedSpawnRate = d.SeedSpawnRate + } + if t.RainAbsorbExtraChance <= 0 { + t.RainAbsorbExtraChance = d.RainAbsorbExtraChance + } + if t.PlantedToSproutMoisture <= 0 { + t.PlantedToSproutMoisture = d.PlantedToSproutMoisture + } + if t.SproutToBudMoisture <= 0 { + t.SproutToBudMoisture = d.SproutToBudMoisture + } + if t.BudToBloomMoisture <= 0 { + t.BudToBloomMoisture = d.BudToBloomMoisture + } + if t.SeedMoistureBoost <= 0 { + t.SeedMoistureBoost = d.SeedMoistureBoost + } + if t.BloomDurationBase <= 0 { + t.BloomDurationBase = d.BloomDurationBase + } + if t.BloomDurationJitter <= 0 { + t.BloomDurationJitter = d.BloomDurationJitter + } + if t.WitherDuration <= 0 { + t.WitherDuration = d.WitherDuration + } + if t.OffspringMin <= 0 { + t.OffspringMin = d.OffspringMin + } + if t.OffspringMax <= 0 { + t.OffspringMax = d.OffspringMax + } + if t.OffspringMax < t.OffspringMin { + t.OffspringMax = t.OffspringMin + } + if t.OffspringSpread <= 0 { + t.OffspringSpread = d.OffspringSpread + } + if t.SeedSpawnRate > 1 { + t.SeedSpawnRate = 1 + } + if t.RainAbsorbExtraChance > 1 { + t.RainAbsorbExtraChance = 1 + } + if t.PresetAggression < 0 { + t.PresetAggression = 0 + } + if t.PresetAggression > 1 { + t.PresetAggression = 1 + } + return t +} + +// gardenPresetAggression maps the same TUI knobs as config_view into [0,1]: +// higher when the user asks for a busier garden (fast + often + many). +func gardenPresetAggression(cfg *config.Config) float64 { + if cfg == nil { + return 0 + } + s := cfg.UI.GardenSeedRate + p := cfg.UI.GardenGrowthPace + often := s > 0.12 + fast := p > 0 && p < 0.9 + manyOff := cfg.UI.GardenOffspringMax >= 4 && cfg.UI.GardenOffspringMin >= 3 + switch { + case often && fast && manyOff: + return 1.0 + case often && fast: + return 0.9 + case often || fast: + return 0.52 + case manyOff: + return 0.32 + default: + return 0 + } +} + +// gardenTargetStormWallSeconds picks a storm-completion time budget from the +// same TUI presets as config_view (growth pace + seed rate). Used to scale +// tuning so GardenSunny tends to land near these wall times at a reference +// width, adjusted by applyGardenStormWallClockScale for tick + width. +func gardenTargetStormWallSeconds(cfg *config.Config) float64 { + if cfg == nil { + return 32 + } + p := cfg.UI.GardenGrowthPace + s := cfg.UI.GardenSeedRate + rareSeed := s > 0 && s < 0.08 + calm := p >= 1.2 + oftenSeed := s > 0.12 + fastPace := p > 0 && p < 0.9 + + switch { + case rareSeed && calm: + return 180 + case rareSeed: + return 150 + case calm: + return 60 + case oftenSeed && fastPace: + return 16 + case oftenSeed || fastPace: + return 28 + default: + return 32 + } +} + +// applyGardenStormWallClockScale stretches resolved garden pacing so the +// storm phase (until ~80% visual fill / GardenSunny) tends toward +// gardenTargetStormWallSeconds, scaled by rain tick (ms/frame) and sqrt(width) +// so wider terminals get proportionally more frames for the same wall time. +func applyGardenStormWallClockScale(t *GardenTuning, cfg *config.Config, rainTickMS, gardenWidth int) { + if t == nil { + return + } + tick := rainTickMS + if tick <= 0 { + tick = config.DefaultUIRainTickMS + } + sec := gardenTargetStormWallSeconds(cfg) + w := float64(gardenWidth) + if w < 12 { + w = 12 + } + const refW = 56.0 + widthNorm := math.Sqrt(w / refW) + targetFrames := sec * (1000.0 / float64(tick)) * widthNorm + + // Empirical baseline: pre–wall-clock tuning tended to finish the storm in + // roughly this many frames at default moisture + seed rates. Ratio maps + // desired wall time into multipliers on thresholds and spawn rate. + const refFrames = 66.0 + scale := targetFrames / refFrames + if scale < 1.0 { + scale = 1.0 + } + // When the TUI asks for fast / often / many, do not smother those dials with + // the same storm-length multiplier we use for calm defaults. + ag := gardenPresetAggression(cfg) + if ag > 0 { + const maxEase = 0.9 + damp := 1.0 - ag*maxEase + if damp < 0.07 { + damp = 0.07 + } + scale = 1.0 + (scale-1.0)*damp + } + // Slightly extra stretch on the path to first bloom; sky seeds are capped + // separately, so moisture still needs headroom to feel gradual. + const moistureBloomStretch = 1.12 + scaleMoist := scale * moistureBloomStretch + + scaleInt := func(v int, m float64) int { + x := int(float64(v)*m + 0.5) + if x < 1 { + return 1 + } + return x + } + t.PlantedToSproutMoisture = scaleInt(t.PlantedToSproutMoisture, scaleMoist) + t.SproutToBudMoisture = scaleInt(t.SproutToBudMoisture, scaleMoist) + t.BudToBloomMoisture = scaleInt(t.BudToBloomMoisture, scaleMoist) + t.BloomDurationBase = scaleInt(t.BloomDurationBase, scale) + t.BloomDurationJitter = scaleInt(t.BloomDurationJitter, scale) + t.WitherDuration = scaleInt(t.WitherDuration, scale) + + if t.SeedMoistureBoost > 0 { + b := int(float64(t.SeedMoistureBoost)/math.Sqrt(scale) + 0.5) + if b < 1 { + b = 1 + } + t.SeedMoistureBoost = b + } + + t.SeedSpawnRate /= scale + if t.SeedSpawnRate < 0.008 { + t.SeedSpawnRate = 0.008 + } + if t.SeedSpawnRate > 0.45 { + t.SeedSpawnRate = 0.45 + } + + t.RainAbsorbExtraChance /= scale*0.85 + 0.15 + if t.RainAbsorbExtraChance < 0.02 { + t.RainAbsorbExtraChance = 0.02 + } + if t.RainAbsorbExtraChance > 0.55 { + t.RainAbsorbExtraChance = 0.55 + } } // flowerCell tracks accumulated rainfall at a column for the advanced animation @@ -44,13 +354,16 @@ type flowerCell struct { // RainBackground manages the animated rain background type RainBackground struct { - Width int - Height int - Drops []RainDrop - Frame int - Mode string // "basic" or "advanced" - Flowers []flowerCell - CloudRow []string // pre-rendered cloud chars per column + Width int + Height int + Drops []RainDrop + Frame int + Mode string // "basic" or "advanced" + Flowers []flowerCell + CloudRow []string // pre-rendered cloud chars per column + GardenPlots []gardenPlot + GardenSunny bool // garden mode: rain finished, sky cleared + Garden GardenTuning // pacing knobs (always resolved to non-zero values) } // NewRainBackground creates a new rain background @@ -61,15 +374,25 @@ func NewRainBackground(width, height int, mode string) *RainBackground { Drops: make([]RainDrop, 0), Frame: 0, Mode: mode, + Garden: DefaultGardenTuning(), } if width > 0 { rb.Flowers = make([]flowerCell, width) rb.CloudRow = rb.buildCloudRow() } + if width > 0 && mode == config.UIRainAnimationGarden { + rb.GardenPlots = make([]gardenPlot, width) + } rb.Reset() return rb } +// SetGardenTuning replaces the active garden tuning. Zero-valued fields in t +// are resolved to defaults; callers can pass partial structs. +func (rb *RainBackground) SetGardenTuning(t GardenTuning) { + rb.Garden = ResolveGardenTuning(t) +} + func (rb *RainBackground) buildCloudRow() []string { row := make([]string, rb.Width) for x := 0; x < rb.Width; x++ { @@ -85,13 +408,21 @@ func (rb *RainBackground) buildCloudRow() []string { // Reset reinitializes all drops func (rb *RainBackground) Reset() { rb.Drops = make([]RainDrop, 0) + rb.GardenSunny = false + if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 { + rb.GardenPlots = make([]gardenPlot, rb.Width) + } startY := 0 - if rb.Mode == config.UIRainAnimationAdvanced { - startY = 1 // leave top row for clouds + if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden { + startY = 1 // leave top row for clouds / sky } targetCount := rb.Width * 2 - for i := 0; i < targetCount; i++ { - rb.spawnDrop(startY) + if rb.Mode == config.UIRainAnimationGarden { + rb.spawnGardenMaintainingDrops(startY, targetCount) + } else { + for i := 0; i < targetCount; i++ { + rb.spawnDrop(startY) + } } } @@ -106,6 +437,7 @@ func (rb *RainBackground) spawnDrop(minY int) { } speed := 1 + rand.Intn(2) // 1 or 2 frames per step char := rainDropChars[rand.Intn(len(rainDropChars))] + isSeed := false if rb.Mode == config.UIRainAnimationMatrix { char = matrixGlyphPool[rand.Intn(len(matrixGlyphPool))] // Rarely swap in a subliminal ASCII cell (single-column safe). @@ -123,19 +455,230 @@ func (rb *RainBackground) spawnDrop(minY int) { Age: 0, MaxAge: rb.Height + rand.Intn(6), Speed: speed, + IsSeed: isSeed, } rb.Drops = append(rb.Drops, drop) } +// spawnDropGarden appends one garden storm sky drop (rain or seed). +func (rb *RainBackground) spawnDropGarden(minY int, isSeed bool) { + if rb.Width <= 0 || rb.Height <= 0 { + return + } + startY := minY + if rb.Height > 2 { + startY = minY + rand.Intn(rb.Height/3) + } + speed := 1 + rand.Intn(2) + char := rainDropChars[rand.Intn(len(rainDropChars))] + if isSeed { + char = "∘" + speed = 2 + rand.Intn(2) // seeds fall a little slower + } + rb.Drops = append(rb.Drops, RainDrop{ + X: rand.Intn(rb.Width), + Y: startY, + Char: char, + ColorIdx: 0, + Age: 0, + MaxAge: rb.Height + rand.Intn(6), + Speed: speed, + IsSeed: isSeed, + }) +} + +func gardenBinomial(n int, p float64) int { + if n <= 0 || p <= 0 { + return 0 + } + if p >= 1 { + return n + } + c := 0 + for i := 0; i < n; i++ { + if rand.Float64() < p { + c++ + } + } + return c +} + +// gardenRandSeedMask picks exactly k true entries among n (uniform subset). +func gardenRandSeedMask(n, k int) []bool { + m := make([]bool, n) + if k <= 0 { + return m + } + if k >= n { + for i := range m { + m[i] = true + } + return m + } + perm := rand.Perm(n) + for i := 0; i < k; i++ { + m[perm[i]] = true + } + return m +} + +func (rb *RainBackground) gardenSeedsInFlight() int { + n := 0 + for i := range rb.Drops { + if rb.Drops[i].IsSeed { + n++ + } + } + return n +} + +// gardenSeedThrottleRelief is in [0,1]: low when plots are empty or early +// growth, high when many columns are in full bloom (or eternal). It relaxes +// the global flying-seed ceiling so young plants see few sky seeds, and mature +// meadows can carry more falling seeds. +func (rb *RainBackground) gardenSeedThrottleRelief() float64 { + if len(rb.GardenPlots) == 0 { + return 0 + } + var sum float64 + for i := range rb.GardenPlots { + switch rb.GardenPlots[i].stage { + case gardenStageNone: + sum += 0 + case gardenStagePlanted: + sum += 0.1 + case gardenStageSprout: + sum += 0.28 + case gardenStageBud: + sum += 0.5 + case gardenStageBloom: + sum += 1.0 + case gardenStageWither: + sum += 0.72 + case gardenStageEternal: + sum += 1.0 + default: + sum += 0 + } + } + return sum / float64(len(rb.GardenPlots)) +} + +// gardenMaxFlyingSkySeeds returns how many seed drops may exist at once for +// this garden maturity (relief). Early stages get a low ceiling; full bloom +// raises it roughly linearly between lo and hi. +func (rb *RainBackground) gardenMaxFlyingSkySeeds(relief float64) int { + if relief < 0 { + relief = 0 + } + if relief > 1 { + relief = 1 + } + rate := rb.Garden.SeedSpawnRate + if rate <= 0 { + rate = DefaultGardenTuning().SeedSpawnRate + } + w := float64(rb.Width) + hi := int(0.5 + w*rate*0.55 + 4) + if hi < 4 { + hi = 4 + } + lo := max(1, hi/5) + ag := rb.Garden.PresetAggression + if ag < 0 { + ag = 0 + } + if ag > 1 { + ag = 1 + } + reliefEff := relief + ag*0.58 + if reliefEff > 1 { + reliefEff = 1 + } + ceiling := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) + if ceiling < 1 { + ceiling = 1 + } + if ceiling > hi { + ceiling = hi + } + return ceiling +} + +// gardenSkySeedsForBatch returns how many of 'need' new sky drops should be +// seeds. Refill often adds many drops in one frame; independent Bernoulli rolls +// would otherwise flood wide terminals with seeds. The count is also clamped +// by gardenMaxFlyingSkySeeds minus seeds already in flight, with the ceiling +// rising as plots reach bloom (gardenSeedThrottleRelief). +func (rb *RainBackground) gardenSkySeedsForBatch(need int) int { + if need <= 0 || rb.GardenSunny { + return 0 + } + rate := rb.Garden.SeedSpawnRate + if rate <= 0 { + return 0 + } + coef := 0.20 + rb.Garden.PresetAggression*0.32 + maxSeeds := int(0.5 + float64(rb.Width)*rate*coef) + if maxSeeds < 1 { + maxSeeds = 1 + } + if maxSeeds > need { + maxSeeds = need + } + raw := gardenBinomial(need, rate) + k := raw + if k > maxSeeds { + k = maxSeeds + } + relief := rb.gardenSeedThrottleRelief() + flying := rb.gardenSeedsInFlight() + slotBudget := rb.gardenMaxFlyingSkySeeds(relief) - flying + if slotBudget <= 0 { + return 0 + } + if k > slotBudget { + k = slotBudget + } + return k +} + +func (rb *RainBackground) spawnGardenMaintainingDrops(minY, targetCount int) { + if rb.Width <= 0 || rb.Height <= 0 { + return + } + for len(rb.Drops) < targetCount { + need := targetCount - len(rb.Drops) + k := rb.gardenSkySeedsForBatch(need) + mask := gardenRandSeedMask(need, k) + for i := 0; i < need; i++ { + rb.spawnDropGarden(minY, mask[i]) + } + } +} + // Update advances the animation by one frame func (rb *RainBackground) Update() { rb.Frame++ minY := 0 maxDropY := rb.Height - 1 - if rb.Mode == config.UIRainAnimationAdvanced { + if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden { minY = 1 - maxDropY = rb.Height - 2 // leave bottom row for flowers + maxDropY = rb.Height - 2 // leave bottom row for plants / flowers + } + + if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny { + rb.gardenAdvancePlotsSunny() + return + } + + // Per-frame water cap: each garden column accepts at most one rain hit + // per Update, with a small chance of accepting a second so dense rain + // still reads as "soaking" without machine-gunning growth thresholds. + var gardenWatered []bool + if rb.Mode == config.UIRainAnimationGarden && !rb.GardenSunny && rb.Width > 0 { + gardenWatered = make([]bool, rb.Width) } for i := range rb.Drops { @@ -183,6 +726,31 @@ func (rb *RainBackground) Update() { if rb.Mode == config.UIRainAnimationAdvanced && p.Y >= maxDropY && rb.Flowers != nil && p.X < len(rb.Flowers) { rb.Flowers[p.X].drops++ } + + if rb.Mode == config.UIRainAnimationGarden && p.Y >= maxDropY && p.Y < rb.Height && rb.GardenPlots != nil && p.X >= 0 && p.X < len(rb.GardenPlots) { + g := &rb.GardenPlots[p.X] + if p.IsSeed { + if g.stage == gardenStageNone { + g.stage = gardenStagePlanted + g.moisture = 0 + g.bloomAge = 0 + g.witherAge = 0 + if len(gardenFlowerAccentHex) > 0 { + g.flowerTint = rand.Intn(len(gardenFlowerAccentHex)) + } + } else { + g.moisture += rb.Garden.SeedMoistureBoost + } + } else if gardenWatered != nil { + if !gardenWatered[p.X] { + rb.gardenWaterPlot(g) + gardenWatered[p.X] = true + } else if rb.Garden.RainAbsorbExtraChance > 0 && rand.Float64() < rb.Garden.RainAbsorbExtraChance { + rb.gardenWaterPlot(g) + } + } + p.Age = p.MaxAge + } } // Remove dead drops (off screen or expired) @@ -195,17 +763,26 @@ func (rb *RainBackground) Update() { rb.Drops = alive // Spawn new drops to maintain count - if rb.Width > 0 && rb.Height > 0 { + if rb.Width > 0 && rb.Height > 0 && (rb.Mode != config.UIRainAnimationGarden || !rb.GardenSunny) { targetCount := rb.Width * 2 - for len(rb.Drops) < targetCount { - rb.spawnDrop(minY) + if rb.Mode == config.UIRainAnimationGarden { + rb.spawnGardenMaintainingDrops(minY, targetCount) + } else { + for len(rb.Drops) < targetCount { + rb.spawnDrop(minY) + } } } - // Periodically refresh cloud row in advanced mode - if rb.Mode == config.UIRainAnimationAdvanced && rb.Frame%30 == 0 && rb.Width > 0 { + // 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 { rb.CloudRow = rb.buildCloudRow() } + + if rb.Mode == config.UIRainAnimationGarden { + rb.gardenAdvancePlots() + rb.gardenMaybeFinishStorm() + } } // Render returns the rain background as a string @@ -236,7 +813,10 @@ func (rb *RainBackground) Render() string { } } - if rb.Mode == config.UIRainAnimationAdvanced { + switch rb.Mode { + case config.UIRainAnimationGarden: + rb.paintGardenOverlays(cells) + case config.UIRainAnimationAdvanced: // Top row: clouds if len(rb.CloudRow) >= rb.Width { cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) @@ -255,9 +835,7 @@ func (rb *RainBackground) Render() string { } } } - } - - if rb.Mode == config.UIRainAnimationMatrix { + case config.UIRainAnimationMatrix: subY := matrixSubliminalBackgroundRow(rb.Height) if subY >= 0 && subY < rb.Height && len(styles) > 0 { dim := lipgloss.NewStyle(). @@ -290,6 +868,334 @@ func (rb *RainBackground) Render() string { return result.String() } +func (rb *RainBackground) gardenMaxAirRows() int { + if rb.Height <= 1 { + return 0 + } + return rb.Height - 1 +} + +func gardenColumnVisualRows(stage, maxRows int) int { + if maxRows <= 0 { + return 0 + } + switch stage { + case gardenStageNone: + return 0 + case gardenStagePlanted: + return min(1, maxRows) + case gardenStageSprout: + return min(2, maxRows) + case gardenStageBud: + return min(3, maxRows) + case gardenStageBloom, gardenStageWither, gardenStageEternal: + return maxRows + default: + return 0 + } +} + +func gardenGlyph(stage, layerFromBottom, visualH int) string { + switch stage { + case gardenStagePlanted: + return "∘" + case gardenStageSprout: + if layerFromBottom == 0 { + return "·" + } + return "╷" + case gardenStageBud: + switch layerFromBottom { + case 0: + return "♦" + case 1: + return "┊" + default: + return "╷" + } + case gardenStageBloom, gardenStageEternal: + if visualH <= 1 { + return "✿" + } + if layerFromBottom == 0 { + return "│" + } + if layerFromBottom == visualH-1 { + return "❀" + } + if layerFromBottom == visualH-2 { + return "✿" + } + return "⌣" + case gardenStageWither: + if layerFromBottom == 0 { + return "˙" + } + if layerFromBottom == visualH-1 { + return "·" + } + return "⁘" + default: + return " " + } +} + +func (rb *RainBackground) gardenWaterPlot(g *gardenPlot) { + if g.stage == gardenStageNone || g.stage == gardenStageEternal { + return + } + if g.stage == gardenStageWither { + return + } + g.moisture++ + switch g.stage { + case gardenStagePlanted: + if g.moisture >= rb.Garden.PlantedToSproutMoisture { + g.stage = gardenStageSprout + g.moisture = 0 + } + case gardenStageSprout: + if g.moisture >= rb.Garden.SproutToBudMoisture { + g.stage = gardenStageBud + g.moisture = 0 + } + case gardenStageBud: + if g.moisture >= rb.Garden.BudToBloomMoisture { + g.stage = gardenStageBloom + g.moisture = 0 + g.bloomAge = 0 + g.maxBloom = rb.gardenRollBloomDuration() + } + } +} + +// gardenRollBloomDuration returns a randomized bloom lifetime in frames. +func (rb *RainBackground) gardenRollBloomDuration() int { + base := rb.Garden.BloomDurationBase + jit := rb.Garden.BloomDurationJitter + if jit > 0 { + base += rand.Intn(jit) + } + if base < 1 { + base = 1 + } + return base +} + +func (rb *RainBackground) gardenAdvancePlots() { + if rb.GardenPlots == nil { + return + } + for i := range rb.GardenPlots { + g := &rb.GardenPlots[i] + switch g.stage { + case gardenStageBloom: + g.bloomAge++ + maxBloom := g.maxBloom + if maxBloom <= 0 { + maxBloom = rb.gardenRollBloomDuration() + g.maxBloom = maxBloom + } + if g.bloomAge >= maxBloom { + g.stage = gardenStageWither + g.witherAge = 0 + } + case gardenStageWither: + g.witherAge++ + if g.witherAge >= rb.Garden.WitherDuration { + g.stage = gardenStageNone + g.moisture = 0 + g.bloomAge = 0 + g.witherAge = 0 + g.maxBloom = 0 + g.flowerTint = 0 + rb.spawnGardenSeedsBurst(i) + } + } + } +} + +func (rb *RainBackground) gardenAdvancePlotsSunny() {} + +func (rb *RainBackground) gardenFillPortion() (num, denom int) { + air := rb.gardenMaxAirRows() + if air <= 0 || rb.Width <= 0 { + return 0, 1 + } + denom = rb.Width * air + if rb.GardenPlots == nil { + return 0, denom + } + for x := 0; x < rb.Width && x < len(rb.GardenPlots); x++ { + num += gardenColumnVisualRows(rb.GardenPlots[x].stage, air) + } + return num, denom +} + +func (rb *RainBackground) gardenMaybeFinishStorm() { + if rb.GardenSunny || rb.GardenPlots == nil { + return + } + num, denom := rb.gardenFillPortion() + if denom <= 0 { + return + } + if num*100 >= denom*80 { + rb.GardenSunny = true + rb.Drops = nil + for i := range rb.GardenPlots { + if rb.GardenPlots[i].stage != gardenStageNone { + rb.GardenPlots[i].stage = gardenStageEternal + rb.GardenPlots[i].moisture = 0 + rb.GardenPlots[i].bloomAge = 0 + rb.GardenPlots[i].witherAge = 0 + } + } + } +} + +func (rb *RainBackground) spawnGardenSeed(x int) { + if rb.Width <= 0 || rb.Height <= 0 || rb.GardenSunny { + return + } + if x < 0 || x >= rb.Width { + return + } + startY := 1 + if rb.Height > 3 { + // Stagger so multiple offspring don't appear in lockstep. + startY = 1 + rand.Intn(2) + } + speed := 2 + rand.Intn(2) + rb.Drops = append(rb.Drops, RainDrop{ + X: x, + Y: startY, + Char: "∘", + ColorIdx: 0, + Age: 0, + MaxAge: rb.Height + 10, + Speed: speed, + IsSeed: true, + }) +} + +// spawnGardenSeedsBurst scatters OffspringMin..OffspringMax seeds around +// originX so a dying plant repopulates a small neighborhood instead of +// directly replacing itself. Offsets are clamped to [0, width-1]. +func (rb *RainBackground) spawnGardenSeedsBurst(originX int) { + if rb.Width <= 0 || rb.Height <= 0 || rb.GardenSunny { + return + } + minN := rb.Garden.OffspringMin + maxN := rb.Garden.OffspringMax + if minN < 1 { + minN = 1 + } + if maxN < minN { + maxN = minN + } + n := minN + if maxN > minN { + n += rand.Intn(maxN - minN + 1) + } + spread := rb.Garden.OffspringSpread + if spread < 0 { + spread = 0 + } + for k := 0; k < n; k++ { + x := originX + if spread > 0 { + x += rand.Intn(2*spread+1) - spread + } + if x < 0 { + x = 0 + } + if x >= rb.Width { + x = rb.Width - 1 + } + rb.spawnGardenSeed(x) + } +} + +func (rb *RainBackground) paintGardenOverlays(cells []string) { + if rb.Width <= 0 || rb.Height <= 0 || rb.GardenPlots == nil { + return + } + air := rb.gardenMaxAirRows() + skyBlue := lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")) + skyLight := lipgloss.NewStyle().Foreground(lipgloss.Color("#B3E5FC")) + sunSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFEB3B")).Bold(true) + cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) + + if rb.GardenSunny { + sunX := rb.Width / 2 + for x := 0; x < rb.Width; x++ { + if x == sunX { + cells[x] = sunSt.Render("☀") + } else if (x+rb.Frame)%3 == 0 { + cells[x] = skyLight.Render("░") + } else { + cells[x] = skyBlue.Render("░") + } + } + } else if len(rb.CloudRow) >= rb.Width { + for x := 0; x < rb.Width; x++ { + cells[x] = cloudStyle.Render(rb.CloudRow[x]) + } + } + + stemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1B5E20")) + leafStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#43A047")) + witherStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6D4C41")) + + for x := 0; x < rb.Width && x < len(rb.GardenPlots); x++ { + g := rb.GardenPlots[x] + h := gardenColumnVisualRows(g.stage, air) + for k := 0; k < h; k++ { + y := rb.Height - 1 - k + ch := gardenGlyph(g.stage, k, h) + var st lipgloss.Style + switch g.stage { + case gardenStageWither: + st = witherStyle + case gardenStageEternal: + switch k { + case 0: + st = stemStyle + case h - 1: + st = gardenFlowerStyle(g.flowerTint).Bold(true) + default: + st = leafStyle + } + default: + switch k { + case 0: + switch { + case g.stage == gardenStagePlanted: + st = lipgloss.NewStyle().Foreground(lipgloss.Color("#5D4037")) + case g.stage >= gardenStageBud: + st = stemStyle + default: + st = leafStyle + } + case h - 1: + if g.stage >= gardenStageBud { + st = gardenFlowerStyle(g.flowerTint) + } else { + st = leafStyle + } + default: + st = leafStyle + } + } + idx := y*rb.Width + x + if idx >= 0 && idx < len(cells) { + cells[idx] = st.Render(ch) + } + } + } +} + // flowerStage returns the growth stage index (0-3) or -1 if no drops yet. func (rb *RainBackground) flowerStage(x int) int { if rb.Flowers == nil || x >= len(rb.Flowers) { @@ -312,14 +1218,32 @@ func (rb *RainBackground) flowerStage(x int) int { // RenderRainWave renders a storm-cloud wave strip for the top of the TUI. // In basic mode it's a wave of drop chars; in advanced mode it shows a cloud line. -func RenderRainWave(width, frame int, mode string) string { +// For garden mode, gardenSunny selects a clear sky strip after the storm ends. +func RenderRainWave(width, frame int, mode string, gardenSunny bool) string { var result strings.Builder styles := rainColorStylesForMode(mode) if len(styles) == 0 { return strings.Repeat("~", width) } - if mode == config.UIRainAnimationAdvanced { + if mode == config.UIRainAnimationGarden && gardenSunny { + skyBlue := lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")) + skyLight := lipgloss.NewStyle().Foreground(lipgloss.Color("#B3E5FC")) + sunSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFEB3B")).Bold(true) + sunX := width / 2 + for x := 0; x < width; x++ { + if x == sunX { + result.WriteString(sunSt.Render("☀")) + } else if (x+frame)%3 == 0 { + result.WriteString(skyLight.Render("░")) + } else { + result.WriteString(skyBlue.Render("░")) + } + } + return result.String() + } + + if mode == config.UIRainAnimationAdvanced || mode == config.UIRainAnimationGarden { // Render a cloud band across the full width cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) for x := 0; x < width; x++ { @@ -397,6 +1321,9 @@ func rainPaletteForMode(mode string) []lipgloss.Color { if mode == config.UIRainAnimationMatrix { return matrixRainColors } + if mode == config.UIRainAnimationGarden { + return gardenRainColors + } if len(activeRainColors) == 0 { return []lipgloss.Color{lipgloss.Color("#4488CC")} } @@ -428,3 +1355,15 @@ var matrixRainColors = []lipgloss.Color{ lipgloss.Color("#55EE77"), lipgloss.Color("#CCFFCC"), } + +// gardenRainColors is a soft rain palette for the growing season. +var gardenRainColors = []lipgloss.Color{ + lipgloss.Color("#1A237E"), + lipgloss.Color("#283593"), + lipgloss.Color("#303F9F"), + lipgloss.Color("#3949AB"), + lipgloss.Color("#5C6BC0"), + lipgloss.Color("#7986CB"), + lipgloss.Color("#9FA8DA"), + lipgloss.Color("#C5CAE9"), +} diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index eeb89aa..eac4cfd 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -28,12 +28,22 @@ func TestRainBackgroundMatrixRenderLineWidths(t *testing.T) { func TestRenderRainWaveMatrixWidth(t *testing.T) { const width = 40 - s := RenderRainWave(width, 7, config.UIRainAnimationMatrix) + s := RenderRainWave(width, 7, config.UIRainAnimationMatrix, false) if got := lipgloss.Width(s); got != width { t.Fatalf("lipgloss.Width(RenderRainWave matrix) = %d, want %d", got, width) } } +func TestRenderRainWaveGardenWidths(t *testing.T) { + const width = 40 + for _, sunny := range []bool{false, true} { + s := RenderRainWave(width, 11, config.UIRainAnimationGarden, sunny) + if got := lipgloss.Width(s); got != width { + t.Fatalf("garden sunny=%v: lipgloss.Width = %d, want %d", sunny, got, width) + } + } +} + func TestMatrixMarqueeCharSingleCell(t *testing.T) { for frame := 0; frame < 2000; frame++ { for x := 0; x < 80; x++ { @@ -55,3 +65,223 @@ func TestMatrixVerticalSubliminalCharSingleCell(t *testing.T) { } } } + +func TestRainBackgroundGardenRenderLineWidths(t *testing.T) { + const w, h = 28, 5 + rb := NewRainBackground(w, h, config.UIRainAnimationGarden) + for i := 0; i < 30; 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 TestGardenSkySeedsForBatchCapped(t *testing.T) { + rb := NewRainBackground(100, 6, config.UIRainAnimationGarden) + rb.Garden.SeedSpawnRate = 0.35 + const need = 200 + for range 400 { + k := rb.gardenSkySeedsForBatch(need) + if k > 12 { + t.Fatalf("gardenSkySeedsForBatch capped too loose: k=%d for need=%d", k, need) + } + } +} + +func TestGardenSkySeedsThrottleByMaturityAndFlight(t *testing.T) { + rb := NewRainBackground(24, 6, config.UIRainAnimationGarden) + rb.Garden.SeedSpawnRate = 0.25 + // Young garden: relief ~0 → tight flying cap; many seeds already aloft → no new sky seeds. + for i := range rb.GardenPlots { + rb.GardenPlots[i] = gardenPlot{stage: gardenStageSprout} + } + for i := 0; i < 8; i++ { + rb.Drops = append(rb.Drops, RainDrop{IsSeed: true, MaxAge: 99, Y: 2, X: i % rb.Width}) + } + if k := rb.gardenSkySeedsForBatch(40); k != 0 { + t.Fatalf("expected 0 new sky seeds when flying count exceeds young cap, got %d", k) + } + // Mature garden: higher relief and ceiling → room for more seeds if few in flight. + rb.Drops = nil + for i := range rb.GardenPlots { + rb.GardenPlots[i] = gardenPlot{stage: gardenStageBloom} + } + rb.Garden.SeedSpawnRate = 1.0 + ceiling := rb.gardenMaxFlyingSkySeeds(rb.gardenSeedThrottleRelief()) + if ceiling < 6 { + t.Fatalf("expected mature garden to allow a generous flying-seed cap, got %d", ceiling) + } + if k := rb.gardenSkySeedsForBatch(40); k < 1 { + t.Fatalf("expected at least one sky seed when mature and sky is clear, got %d", k) + } +} + +func TestAggressivePresetLowersWallClockStretch(t *testing.T) { + slow := ResolveGardenTuning(GardenTuning{}) + applyGardenStormWallClockScale(&slow, nil, 150, 56) + fast := ResolveGardenTuning(GardenTuning{}) + cfg := &config.Config{} + cfg.UI.GardenGrowthPace = 0.78 + cfg.UI.GardenSeedRate = 0.15 + applyGardenStormWallClockScale(&fast, cfg, 150, 56) + if fast.PlantedToSproutMoisture >= slow.PlantedToSproutMoisture { + t.Fatalf("fast+often should reduce wall-clock moisture vs default, slow=%d fast=%d", + slow.PlantedToSproutMoisture, fast.PlantedToSproutMoisture) + } +} + +func TestGardenPresetAggressionFastOftenMany(t *testing.T) { + cfg := &config.Config{} + cfg.UI.GardenGrowthPace = 0.78 + cfg.UI.GardenSeedRate = 0.15 + cfg.UI.GardenOffspringMin = 3 + cfg.UI.GardenOffspringMax = 4 + if g := gardenPresetAggression(cfg); g < 0.99 { + t.Fatalf("expected max aggression for fast+often+many, got %v", g) + } + cfg2 := &config.Config{} + cfg2.UI.GardenSeedRate = 0.15 + if g := gardenPresetAggression(cfg2); g < 0.45 || g > 0.6 { + t.Fatalf("expected mid aggression for often-only, got %v", g) + } +} + +func TestApplyGardenStormWallClockScaleWiderAndRareSlower(t *testing.T) { + cfgNormal := &config.Config{} + narrow := ResolveGardenTuning(GardenTuning{}) + wide := narrow + applyGardenStormWallClockScale(&narrow, cfgNormal, 150, 40) + applyGardenStormWallClockScale(&wide, cfgNormal, 150, 100) + if wide.PlantedToSproutMoisture <= narrow.PlantedToSproutMoisture { + t.Fatalf("wider garden width should increase moisture thresholds (got narrow=%d wide=%d)", + narrow.PlantedToSproutMoisture, wide.PlantedToSproutMoisture) + } + + cfgRare := &config.Config{} + cfgRare.UI.GardenSeedRate = 0.06 + rare := ResolveGardenTuning(GardenTuning{SeedSpawnRate: 0.06}) + applyGardenStormWallClockScale(&rare, cfgRare, 150, 56) + if rare.PlantedToSproutMoisture <= narrow.PlantedToSproutMoisture { + t.Fatalf("rare seed preset should slow growth vs normal (rare=%d narrow=%d)", + rare.PlantedToSproutMoisture, narrow.PlantedToSproutMoisture) + } + if rare.SeedSpawnRate >= narrow.SeedSpawnRate { + t.Fatalf("rare wall-clock scale should lower effective seed rate (rare=%v narrow=%v)", + rare.SeedSpawnRate, narrow.SeedSpawnRate) + } +} + +func TestResolveGardenTuningFillsZeroDefaults(t *testing.T) { + d := DefaultGardenTuning() + got := ResolveGardenTuning(GardenTuning{}) + if got != d { + t.Fatalf("zero tuning should equal defaults\n got: %#v\nwant: %#v", got, d) + } + + got = ResolveGardenTuning(GardenTuning{ + PlantedToSproutMoisture: 99, + OffspringMin: 5, + OffspringMax: 2, // out of order; resolver should clamp up + }) + if got.PlantedToSproutMoisture != 99 { + t.Fatalf("user override not preserved: got %d", got.PlantedToSproutMoisture) + } + if got.OffspringMin != 5 || got.OffspringMax < got.OffspringMin { + t.Fatalf("offspring bounds wrong: min=%d max=%d", got.OffspringMin, got.OffspringMax) + } + if got.SeedSpawnRate != d.SeedSpawnRate { + t.Fatalf("unset SeedSpawnRate should fall back to default %v, got %v", d.SeedSpawnRate, got.SeedSpawnRate) + } +} + +func TestGardenWaterCapPerColumnPerFrame(t *testing.T) { + const w, h = 8, 6 + rb := NewRainBackground(w, h, config.UIRainAnimationGarden) + // Disable extra-absorb so the cap is hard. The resolver treats zero as + // "unset" by design (matches the rest of the TOML config), so we override + // the field directly after resolving. + rb.Garden.RainAbsorbExtraChance = 0 + rb.Drops = nil + col := 3 + rb.GardenPlots[col] = gardenPlot{stage: gardenStagePlanted} + soilY := h - 2 + // Pile many rain drops onto the same soil cell within one frame. + for i := 0; i < 20; i++ { + rb.Drops = append(rb.Drops, RainDrop{ + X: col, + Y: soilY, + Char: "·", + MaxAge: h * 4, + Speed: 1, // ensure they advance and trigger soil-band logic + }) + } + startMoisture := rb.GardenPlots[col].moisture + rb.Update() + got := rb.GardenPlots[col].moisture - startMoisture + if got > 1 { + t.Fatalf("water cap broken: column gained %d moisture in one frame, want <=1", got) + } + if got < 1 { + t.Fatalf("expected at least 1 moisture point from many drops, got %d", got) + } +} + +func TestGardenDeathScattersOffspring(t *testing.T) { + const w, h = 24, 6 + rb := NewRainBackground(w, h, config.UIRainAnimationGarden) + rb.SetGardenTuning(GardenTuning{ + OffspringMin: 2, + OffspringMax: 3, + OffspringSpread: 4, + }) + rb.Drops = nil + origin := 12 + // Park a plot on the verge of death. + rb.GardenPlots[origin] = gardenPlot{ + stage: gardenStageWither, + witherAge: rb.Garden.WitherDuration - 1, + } + rb.gardenAdvancePlots() + + if rb.GardenPlots[origin].stage != gardenStageNone { + t.Fatalf("expected plot to transition to None on death, got %d", rb.GardenPlots[origin].stage) + } + seedCount := 0 + for _, d := range rb.Drops { + if !d.IsSeed { + continue + } + seedCount++ + offset := d.X - origin + if offset < -rb.Garden.OffspringSpread || offset > rb.Garden.OffspringSpread { + t.Fatalf("seed at x=%d landed outside spread window around origin %d", d.X, origin) + } + } + if seedCount < rb.Garden.OffspringMin || seedCount > rb.Garden.OffspringMax { + t.Fatalf("expected %d-%d offspring seeds, got %d", rb.Garden.OffspringMin, rb.Garden.OffspringMax, seedCount) + } +} + +func TestGardenBackgroundFinishesStorm(t *testing.T) { + const w, h = 32, 5 + rb := NewRainBackground(w, h, config.UIRainAnimationGarden) + rb.GardenSunny = false + for x := 0; x < w; x++ { + rb.GardenPlots[x].stage = gardenStageEternal + } + rb.gardenMaybeFinishStorm() + if !rb.GardenSunny { + t.Fatal("expected storm to end when garden is visually full") + } + if len(rb.Drops) != 0 { + t.Fatalf("expected drops cleared, got %d", len(rb.Drops)) + } +} diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 7aa7b68..b2d1c9d 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -255,7 +255,9 @@ func NewRepoSelectorModelStream( } } - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) + bgW := resolveRainBackgroundWidth(80) + rainBg := NewRainBackground(bgW, 5, animMode) + rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) return RepoSelectorModel{ repos: nil, @@ -337,6 +339,7 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.windowHeight = msg.Height bgW := resolveRainBackgroundWidth(msg.Width) m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode) + m.applyGardenTuning(m.rainBg) m = m.withClampedPathScroll() m.scrollOffset = m.clampScroll(m.scrollOffset, m.cursor, m.repoListVisibleCount(), len(m.repos)) m.ignoredScrollOffset = m.clampScroll(m.ignoredScrollOffset, m.ignoredCursor, m.ignoredListVisibleCount(), len(m.ignoredEntries)) @@ -712,6 +715,64 @@ func resolveRainBackgroundWidth(terminalWidth int) int { return w } +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) +} + +// gardenTuningFromConfig translates the (advanced, TOML-only) garden_* +// fields from UIConfig into a GardenTuning. Zero values pass through to +// ResolveGardenTuning so they fall back to DefaultGardenTuning(). +// +// GardenGrowthPace scales the three stage moisture thresholds together when +// set. rainTickMS and gardenWidth tune storm pacing toward target wall times +// (~32s default, ~150s rare seeds, etc.); see applyGardenStormWallClockScale. +func gardenTuningFromConfig(cfg *config.Config, rainTickMS, gardenWidth int) GardenTuning { + t := GardenTuning{} + if cfg != nil { + t = GardenTuning{ + SeedSpawnRate: cfg.UI.GardenSeedRate, + BloomDurationBase: cfg.UI.GardenBloomDurationBase, + BloomDurationJitter: cfg.UI.GardenBloomDurationJitter, + WitherDuration: cfg.UI.GardenWitherDuration, + OffspringMin: cfg.UI.GardenOffspringMin, + OffspringMax: cfg.UI.GardenOffspringMax, + OffspringSpread: cfg.UI.GardenOffspringSpread, + RainAbsorbExtraChance: 0, + } + if pace := cfg.UI.GardenGrowthPace; pace > 0 { + def := DefaultGardenTuning() + scale := func(v int) int { + s := int(float64(v)*pace + 0.5) + if s < 1 { + s = 1 + } + return s + } + t.PlantedToSproutMoisture = scale(def.PlantedToSproutMoisture) + t.SproutToBudMoisture = scale(def.SproutToBudMoisture) + t.BudToBloomMoisture = scale(def.BudToBloomMoisture) + } + } + resolved := ResolveGardenTuning(t) + resolved.PresetAggression = gardenPresetAggression(cfg) + applyGardenStormWallClockScale(&resolved, cfg, rainTickMS, gardenWidth) + return resolved +} + +// applyGardenTuning re-applies the model's config-derived garden tuning to +// rb; safe to call any time after rb is (re-)created. +func (m RepoSelectorModel) applyGardenTuning(rb *RainBackground) { + if rb == nil { + return + } + tick := config.DefaultUIRainTickMS + if m.cfg != nil && m.cfg.UI.RainTickMS > 0 { + tick = m.cfg.UI.RainTickMS + } + rb.SetGardenTuning(gardenTuningFromConfig(m.cfg, tick, rb.Width)) +} + // clampCellWidth keeps one screen row within maxCells using lipgloss truncation. // Degeneracy: maxCells < 1 means "no usable width" — return s unchanged; empty s // is also a no-op. For maxCells == 1, truncation still runs (single visible cell). diff --git a/internal/ui/view_layout.go b/internal/ui/view_layout.go index b9981c4..a46d351 100644 --- a/internal/ui/view_layout.go +++ b/internal/ui/view_layout.go @@ -16,7 +16,7 @@ func (m RepoSelectorModel) mainViewHeaderBlock() string { if m.rainVisible() { s.WriteString(m.rainBg.Render()) s.WriteString("\n") - s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) + s.WriteString(m.renderRainWaveStrip(rainW)) s.WriteString("\n\n") } titleGradient := lipgloss.NewStyle(). @@ -227,7 +227,7 @@ func (m RepoSelectorModel) ignoredViewHeaderBlock() string { if m.rainVisible() { s.WriteString(m.rainBg.Render()) s.WriteString("\n") - s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) + s.WriteString(m.renderRainWaveStrip(rainW)) s.WriteString("\n\n") } s.WriteString(m.renderIgnoredViewTitle())