From c7d018ff207f3aac338bef8d1a5a2755079532e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 16:13:14 +0000 Subject: [PATCH 1/7] Add matrix rain animation mode and fix panel width normalization Introduce ui.rain_animation_mode "matrix" with falling half-width glyphs and a green gradient, matching the existing column/drift pattern. Settings TUI lists the new option. Normalize each inner panel line with lipgloss Width so rows with wide emoji cannot exceed the inner cell count and skew rounded borders. Co-authored-by: Ben Schellenberger --- internal/config/defaults.go | 3 +- internal/config/types.go | 4 +- internal/ui/config_view.go | 1 + internal/ui/panel_layout.go | 24 ++++++++++- internal/ui/panel_layout_test.go | 11 ++++++ internal/ui/rain_bg.go | 68 ++++++++++++++++++++++++++++---- internal/ui/rain_bg_test.go | 35 ++++++++++++++++ 7 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 internal/ui/rain_bg_test.go diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 57cca20..fd3a6a0 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -113,7 +113,8 @@ mainline_patterns = [] # Show rain animation in the interactive repo selector (toggle live with 'r') show_rain_animation = true -# Animation mode: "basic" (rain drops only) or "advanced" (clouds + rain + flowers) +# Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), +# or "matrix" (falling code characters) rain_animation_mode = "basic" # Show flavor quotes in the TUI banner diff --git a/internal/config/types.go b/internal/config/types.go index 77acd80..ea5d6c3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -56,7 +56,8 @@ type UIConfig struct { // Automatically suppressed when the terminal is too short. ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"` - // Animation mode: "basic" (rain drops) or "advanced" (clouds + rain + flowers). + // Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), + // or "matrix" (falling code glyphs in the same column pattern). RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"` // Show flavor quotes: TUI banner plus CLI motivation lines. @@ -89,6 +90,7 @@ const ( UIRainAnimationBasic = "basic" UIRainAnimationAdvanced = "advanced" + UIRainAnimationMatrix = "matrix" ) // UIColorProfiles returns valid built-in UI color profile names. diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index 9373312..a0ff448 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -40,6 +40,7 @@ var configRows = []configRow{ {label: "Rain animation mode", kind: configRowEnum, options: []string{ config.UIRainAnimationBasic, config.UIRainAnimationAdvanced, + config.UIRainAnimationMatrix, }}, {label: "Show flavor quotes", kind: configRowBool}, {label: "Flavor quote behavior", kind: configRowEnum, options: []string{ diff --git a/internal/ui/panel_layout.go b/internal/ui/panel_layout.go index 33f8ce6..a2155ae 100644 --- a/internal/ui/panel_layout.go +++ b/internal/ui/panel_layout.go @@ -1,6 +1,10 @@ package ui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) // Horizontal layout for the main Bubble Tea panel (must stay consistent across // repo list, ignored list, settings, rain banner, and PathWidthFor). @@ -66,6 +70,22 @@ func renderMainPanelBox(innerBlockWidth int, inner string) string { return boxStyle.Render(inner) } cells := panelInnerLipglossWidth(innerBlockWidth) - normalized := lipgloss.NewStyle().Width(cells).Render(inner) + normalized := normalizePanelInnerLines(inner, cells) return boxStyle.Width(innerBlockWidth).Render(normalized) } + +// normalizePanelInnerLines pads each logical line to exactly `cells` lipgloss +// cells. Applying Width() to the whole block wraps at word boundaries and can +// leave a row visually wider than `cells` when it contains wide glyphs (emoji), +// which stretches the rounded border. Per-line Width avoids that. +func normalizePanelInnerLines(inner string, cells int) string { + if cells < 1 { + return inner + } + pad := lipgloss.NewStyle().Width(cells) + lines := strings.Split(inner, "\n") + for i, line := range lines { + lines[i] = pad.Render(line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/ui/panel_layout_test.go b/internal/ui/panel_layout_test.go index e3c76ae..bf34759 100644 --- a/internal/ui/panel_layout_test.go +++ b/internal/ui/panel_layout_test.go @@ -61,3 +61,14 @@ func TestRenderMainPanelBoxWithEmojiLine(t *testing.T) { t.Fatalf("inconsistent line widths (border gaps on some terminals): %v", widths) } } + +func TestNormalizePanelInnerLinesWideEmojiRow(t *testing.T) { + cells := panelInnerLipglossWidth(40) + inner := "ok\n🌧️ GIT RAIN — SETTINGS\nok" + norm := normalizePanelInnerLines(inner, cells) + for i, line := range strings.Split(norm, "\n") { + if got := lipgloss.Width(line); got != cells { + t.Fatalf("line %d: width %d, want %d\n%q", i, got, cells, line) + } + } +} diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index 62cd11a..6690672 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -11,6 +11,15 @@ import ( var rainDropChars = [...]string{"│", "╵", "·", "˙", "╷", "⁚", "⋮"} +// matrixGlyphPool is half-width katakana + digits + symbols for a Matrix-style fall. +var matrixGlyphPool = [...]string{ + "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", + "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", + "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ン", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + ":", ";", "<", ">", "?", "@", "#", "$", "%", "^", "&", "*", "+", "=", +} + // cloudChars for advanced mode top row var cloudChars = [...]string{"☁", "░", "▒", "▓", "█"} @@ -96,10 +105,14 @@ func (rb *RainBackground) spawnDrop(minY int) { startY = minY + rand.Intn(rb.Height/3) } speed := 1 + rand.Intn(2) // 1 or 2 frames per step + char := rainDropChars[rand.Intn(len(rainDropChars))] + if rb.Mode == config.UIRainAnimationMatrix { + char = matrixGlyphPool[rand.Intn(len(matrixGlyphPool))] + } drop := RainDrop{ X: rand.Intn(rb.Width), Y: startY, - Char: rainDropChars[rand.Intn(len(rainDropChars))], + Char: char, ColorIdx: 0, Age: 0, MaxAge: rb.Height + rand.Intn(6), @@ -140,12 +153,12 @@ func (rb *RainBackground) Update() { p.X = rb.Width - 1 } - // Color gradient: top (dark) → bottom (bright/white) + // Color gradient: top (dark) → bottom (bright) progress := float64(p.Y-minY) / float64(rb.Height-minY) if progress < 0 { progress = 0 } - paletteLen := len(activeRainColors) + paletteLen := len(rainPaletteForMode(rb.Mode)) if paletteLen == 0 { paletteLen = 1 } @@ -194,11 +207,14 @@ func (rb *RainBackground) Render() string { cells[i] = " " } - styles := rainColorStyles() + styles := rainColorStylesForMode(rb.Mode) // Place raindrops for _, p := range rb.Drops { if p.Y >= 0 && p.Y < rb.Height && p.X >= 0 && p.X < rb.Width { + if len(styles) == 0 { + continue + } safeIdx := p.ColorIdx % len(styles) if safeIdx < 0 { safeIdx += len(styles) @@ -266,7 +282,7 @@ func (rb *RainBackground) flowerStage(x int) int { // 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 { var result strings.Builder - styles := rainColorStyles() + styles := rainColorStylesForMode(mode) if len(styles) == 0 { return strings.Repeat("~", width) } @@ -292,6 +308,20 @@ func RenderRainWave(width, frame int, mode string) string { return result.String() } + if mode == config.UIRainAnimationMatrix { + for x := 0; x < width; x++ { + phase := float64(frame)*0.075 + float64(x)*0.24 + y := 0.75*math.Sin(float64(x)*0.24+phase) + 0.25*math.Sin(float64(x)*0.11+phase*0.6) + char := matrixGlyphPool[(x+frame+int(y*7))%len(matrixGlyphPool)] + colorIdx := int(float64(x) / float64(width) * float64(len(styles)-1)) + if colorIdx >= len(styles) { + colorIdx = len(styles) - 1 + } + result.WriteString(styles[colorIdx].Render(char)) + } + return result.String() + } + // Basic mode: a sine-wave of drop characters at varying depths for x := 0; x < width; x++ { phase := float64(frame) * 0.075 @@ -321,16 +351,38 @@ func RenderRainWave(width, frame int, mode string) string { return result.String() } -func rainColorStyles() []lipgloss.Style { +func rainPaletteForMode(mode string) []lipgloss.Color { + if mode == config.UIRainAnimationMatrix { + return matrixRainColors + } if len(activeRainColors) == 0 { + return []lipgloss.Color{lipgloss.Color("#4488CC")} + } + return activeRainColors +} + +func rainColorStylesForMode(mode string) []lipgloss.Style { + colors := rainPaletteForMode(mode) + if len(colors) == 0 { return []lipgloss.Style{ lipgloss.NewStyle().Foreground(lipgloss.Color("#4488CC")), } } - styles := make([]lipgloss.Style, len(activeRainColors)) - for i, color := range activeRainColors { + styles := make([]lipgloss.Style, len(colors)) + for i, color := range colors { styles[i] = lipgloss.NewStyle().Foreground(color) } return styles } +// matrixRainColors is a green terminal-rain palette (dark → bright). +var matrixRainColors = []lipgloss.Color{ + lipgloss.Color("#001A00"), + lipgloss.Color("#003B00"), + lipgloss.Color("#006400"), + lipgloss.Color("#008F11"), + lipgloss.Color("#00AA22"), + lipgloss.Color("#22CC44"), + lipgloss.Color("#55EE77"), + lipgloss.Color("#CCFFCC"), +} diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go new file mode 100644 index 0000000..004e113 --- /dev/null +++ b/internal/ui/rain_bg_test.go @@ -0,0 +1,35 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/git-rain/git-rain/internal/config" +) + +func TestRainBackgroundMatrixRenderLineWidths(t *testing.T) { + const w, h = 24, 5 + rb := NewRainBackground(w, h, config.UIRainAnimationMatrix) + for i := 0; i < 20; 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 TestRenderRainWaveMatrixWidth(t *testing.T) { + const width = 40 + s := RenderRainWave(width, 7, config.UIRainAnimationMatrix) + if got := lipgloss.Width(s); got != width { + t.Fatalf("lipgloss.Width(RenderRainWave matrix) = %d, want %d", got, width) + } +} From 77da43fd331bbe52a60387a063c3876bc4aa8122 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sat, 18 Apr 2026 02:04:40 -0400 Subject: [PATCH 2/7] Add garden rain animation mode Seeds and rain fall under a cloud row; rain waters plots through growth stages to full bloom, then wither cycles spawn new seeds. When plant visuals cover about 80% of the rain strip, rain stops, the sky clears to sun and blue tones, and plants stay in an eternal bloom state. Settings UI lists the new mode; switching animation mode recreates the background so garden state initializes correctly. --- internal/config/types.go | 3 +- internal/ui/config_view.go | 10 +- internal/ui/rain_bg.go | 390 +++++++++++++++++++++++++++++++++-- internal/ui/rain_bg_test.go | 46 ++++- internal/ui/repo_selector.go | 5 + internal/ui/view_layout.go | 4 +- 6 files changed, 435 insertions(+), 23 deletions(-) diff --git a/internal/config/types.go b/internal/config/types.go index ea5d6c3..b978e25 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -57,7 +57,7 @@ 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), 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. @@ -91,6 +91,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..b5de154 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{ @@ -251,7 +252,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") } @@ -342,7 +343,12 @@ 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.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 6690672..32a1920 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -35,6 +35,25 @@ 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 } // flowerCell tracks accumulated rainfall at a column for the advanced animation @@ -44,13 +63,15 @@ 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 } // NewRainBackground creates a new rain background @@ -66,6 +87,9 @@ func NewRainBackground(width, height int, mode string) *RainBackground { rb.Flowers = make([]flowerCell, width) rb.CloudRow = rb.buildCloudRow() } + if width > 0 && mode == config.UIRainAnimationGarden { + rb.GardenPlots = make([]gardenPlot, width) + } rb.Reset() return rb } @@ -85,9 +109,13 @@ 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++ { @@ -106,9 +134,17 @@ 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))] } + if rb.Mode == config.UIRainAnimationGarden && !rb.GardenSunny { + if rand.Float64() < 0.28 { + isSeed = true + char = "∘" + speed = 2 + rand.Intn(2) // seeds fall a little slower + } + } drop := RainDrop{ X: rand.Intn(rb.Width), Y: startY, @@ -117,6 +153,7 @@ func (rb *RainBackground) spawnDrop(minY int) { Age: 0, MaxAge: rb.Height + rand.Intn(6), Speed: speed, + IsSeed: isSeed, } rb.Drops = append(rb.Drops, drop) } @@ -127,9 +164,14 @@ func (rb *RainBackground) Update() { 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 } for i := range rb.Drops { @@ -171,6 +213,22 @@ 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 && 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 + } else { + g.moisture += 2 + } + } else { + rb.gardenWaterPlot(g) + } + } } // Remove dead drops (off screen or expired) @@ -183,17 +241,22 @@ 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) } } - // 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 @@ -224,7 +287,9 @@ func (rb *RainBackground) Render() string { } } - if rb.Mode == config.UIRainAnimationAdvanced { + if rb.Mode == config.UIRainAnimationGarden { + rb.paintGardenOverlays(cells) + } else if rb.Mode == config.UIRainAnimationAdvanced { // Top row: clouds if len(rb.CloudRow) >= rb.Width { cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) @@ -258,6 +323,264 @@ 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 >= 4 { + g.stage = gardenStageSprout + g.moisture = 0 + } + case gardenStageSprout: + if g.moisture >= 6 { + g.stage = gardenStageBud + g.moisture = 0 + } + case gardenStageBud: + if g.moisture >= 8 { + g.stage = gardenStageBloom + g.moisture = 0 + g.bloomAge = 0 + } + } +} + +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 := 36 + (i % 19) + if g.bloomAge >= maxBloom { + g.stage = gardenStageWither + g.witherAge = 0 + } + case gardenStageWither: + g.witherAge++ + if g.witherAge >= 18 { + g.stage = gardenStageNone + g.moisture = 0 + g.bloomAge = 0 + g.witherAge = 0 + rb.spawnGardenSeed(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 + rb.Drops = append(rb.Drops, RainDrop{ + X: x, + Y: startY, + Char: "∘", + ColorIdx: 0, + Age: 0, + MaxAge: rb.Height + 10, + Speed: 2, + IsSeed: true, + }) +} + +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")) + flowerStyle := lipgloss.NewStyle().Foreground(activeProfile().flowerColor) + witherStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6D4C41")) + eternalFlower := lipgloss.NewStyle().Foreground(lipgloss.Color("#F8BBD0")).Bold(true) + + 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 { + case k == 0: + st = stemStyle + case k == h-1: + st = eternalFlower + default: + st = leafStyle + } + default: + switch { + case g.stage == gardenStagePlanted && k == 0: + st = lipgloss.NewStyle().Foreground(lipgloss.Color("#5D4037")) + case k == 0 && g.stage >= gardenStageBud: + st = stemStyle + case k >= h-1 && g.stage >= gardenStageBud: + st = flowerStyle + 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) { @@ -280,14 +603,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++ { @@ -355,6 +696,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")} } @@ -386,3 +730,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 004e113..7370612 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -28,8 +28,52 @@ 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 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 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..cdde97b 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -712,6 +712,11 @@ 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) +} + // 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()) From 45bada1f6f06ed48d2cb8b5ac5f622023a064cd1 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sat, 18 Apr 2026 02:25:28 -0400 Subject: [PATCH 3/7] Slow garden growth and scatter offspring on death Garden mode previously raced through plants because every raindrop that touched the soil row added moisture and lifecycle thresholds were small. Each garden column now accepts at most one rain hit per frame (with a small probabilistic chance of an extra hit so dense rain still reads as soaking), moisture thresholds are larger, and bloom/wither timing has a randomized lifetime so plants don't all die in unison. Dying plants now scatter 2-3 seeds at jittered X positions around the parent column instead of self-replacing, giving the meadow a more organic spread before the storm clears. Tuning is centralized in a new GardenTuning struct on RainBackground. Defaults live in DefaultGardenTuning, and a small set of advanced TOML keys under [ui] (garden_seed_rate, garden_growth_pace, bloom/wither durations, offspring bounds) feed through ResolveGardenTuning. The settings TUI is intentionally left alone; the keys are documented in README and the example config so power users can tweak pacing without cluttering the in-app picker. --- README.md | 30 +++++ internal/config/defaults.go | 17 ++- internal/config/loader.go | 11 ++ internal/config/types.go | 33 ++++++ internal/ui/config_view.go | 1 + internal/ui/rain_bg.go | 211 +++++++++++++++++++++++++++++++++-- internal/ui/rain_bg_test.go | 91 +++++++++++++++ internal/ui/repo_selector.go | 49 ++++++++ 8 files changed, 430 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8bc9d60..5fea086 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.10 # 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..a6b296c 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.10 # fraction of new sky drops that are 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 ` } 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 b978e25..bbf6ead 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -76,6 +76,39 @@ 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 (advanced; not surfaced in the settings TUI). + // Any field left at its zero value falls back to the built-in default, + // matching the existing pattern used by RainTickMS. + + // GardenSeedRate is the probability (0..1) that a new sky drop falls as + // a seed. Lower values mean rarer seedings; default ~0.10. + 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 2 and 3). + 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 ( diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index b5de154..ac64d13 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -349,6 +349,7 @@ func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorMo } 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 32a1920..f1b8b57 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -54,6 +54,110 @@ type gardenPlot struct { moisture int bloomAge int witherAge int + maxBloom int // randomized per-bloom duration; computed when entering bloom +} + +// 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 +} + +// 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 leave 2-3 nearby seeds. +func DefaultGardenTuning() GardenTuning { + return GardenTuning{ + SeedSpawnRate: 0.10, + RainAbsorbExtraChance: 0.15, + PlantedToSproutMoisture: 10, + SproutToBudMoisture: 14, + BudToBloomMoisture: 18, + SeedMoistureBoost: 2, + BloomDurationBase: 60, + BloomDurationJitter: 40, + WitherDuration: 28, + OffspringMin: 2, + OffspringMax: 3, + OffspringSpread: 3, + } +} + +// 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 + } + return t } // flowerCell tracks accumulated rainfall at a column for the advanced animation @@ -71,7 +175,8 @@ type RainBackground struct { Flowers []flowerCell CloudRow []string // pre-rendered cloud chars per column GardenPlots []gardenPlot - GardenSunny bool // garden mode: rain finished, sky cleared + GardenSunny bool // garden mode: rain finished, sky cleared + Garden GardenTuning // pacing knobs (always resolved to non-zero values) } // NewRainBackground creates a new rain background @@ -82,6 +187,7 @@ 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) @@ -94,6 +200,12 @@ func NewRainBackground(width, height int, mode string) *RainBackground { 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++ { @@ -139,7 +251,7 @@ func (rb *RainBackground) spawnDrop(minY int) { char = matrixGlyphPool[rand.Intn(len(matrixGlyphPool))] } if rb.Mode == config.UIRainAnimationGarden && !rb.GardenSunny { - if rand.Float64() < 0.28 { + if rand.Float64() < rb.Garden.SeedSpawnRate { isSeed = true char = "∘" speed = 2 + rand.Intn(2) // seeds fall a little slower @@ -174,6 +286,14 @@ func (rb *RainBackground) Update() { 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 { p := &rb.Drops[i] p.Age++ @@ -223,10 +343,15 @@ func (rb *RainBackground) Update() { g.bloomAge = 0 g.witherAge = 0 } else { - g.moisture += 2 + 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) } - } else { - rb.gardenWaterPlot(g) } } } @@ -405,24 +530,38 @@ func (rb *RainBackground) gardenWaterPlot(g *gardenPlot) { g.moisture++ switch g.stage { case gardenStagePlanted: - if g.moisture >= 4 { + if g.moisture >= rb.Garden.PlantedToSproutMoisture { g.stage = gardenStageSprout g.moisture = 0 } case gardenStageSprout: - if g.moisture >= 6 { + if g.moisture >= rb.Garden.SproutToBudMoisture { g.stage = gardenStageBud g.moisture = 0 } case gardenStageBud: - if g.moisture >= 8 { + 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 @@ -432,19 +571,24 @@ func (rb *RainBackground) gardenAdvancePlots() { switch g.stage { case gardenStageBloom: g.bloomAge++ - maxBloom := 36 + (i % 19) + 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 >= 18 { + if g.witherAge >= rb.Garden.WitherDuration { g.stage = gardenStageNone g.moisture = 0 g.bloomAge = 0 g.witherAge = 0 - rb.spawnGardenSeed(i) + g.maxBloom = 0 + rb.spawnGardenSeedsBurst(i) } } } @@ -497,6 +641,11 @@ func (rb *RainBackground) spawnGardenSeed(x int) { 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, @@ -504,11 +653,49 @@ func (rb *RainBackground) spawnGardenSeed(x int) { ColorIdx: 0, Age: 0, MaxAge: rb.Height + 10, - Speed: 2, + 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 diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 7370612..81c4973 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -62,6 +62,97 @@ func TestRainBackgroundGardenRenderLineWidths(t *testing.T) { } } +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) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index cdde97b..e4d1b53 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -256,6 +256,7 @@ func NewRepoSelectorModelStream( } rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) + rainBg.SetGardenTuning(gardenTuningFromConfig(cfg)) return RepoSelectorModel{ repos: nil, @@ -337,6 +338,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)) @@ -717,6 +719,53 @@ func (m RepoSelectorModel) renderRainWaveStrip(width int) string { 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 acts as a single user-friendly dial: it scales the three +// stage moisture thresholds together. Per-threshold overrides are intentionally +// not exposed to keep the surface area small; tweak the constants directly if +// you need finer control. +func gardenTuningFromConfig(cfg *config.Config) GardenTuning { + if cfg == nil { + return GardenTuning{} + } + 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) + } + return t +} + +// 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 + } + rb.SetGardenTuning(gardenTuningFromConfig(m.cfg)) +} + // 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). From 5533a10b2ede776c8f296e1dc224b5e269b28b02 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 00:06:33 -0400 Subject: [PATCH 4/7] feat(ui): garden pacing, smart seeds, and multi-color flowers Scale storm completion toward wall-clock targets using rain_tick_ms, terminal width, and growth/seed TUI presets. Lower default moisture and seed density; cap sky seeds per refill batch. Throttle flying seeds by count and a stage-based relief curve so young beds stay sparse and mature bloom can carry more seeds. Assign a stable per-column flower tint when a seed first plants; bloom and eternal heads draw from a shared accent palette. --- internal/config/defaults.go | 2 +- internal/config/types.go | 13 +- internal/ui/config_view.go | 172 +++++++++++++++- internal/ui/rain_bg.go | 383 ++++++++++++++++++++++++++++++++--- internal/ui/rain_bg_test.go | 64 ++++++ internal/ui/repo_selector.go | 70 ++++--- 6 files changed, 628 insertions(+), 76 deletions(-) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index a6b296c..496098f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -138,7 +138,7 @@ color_profile = "storm" # (or at 0) to use the built-in defaults; tweak to make growth slower or # offspring more (or less) prolific. # -# garden_seed_rate = 0.10 # fraction of new sky drops that are seeds (0..1) +# 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 diff --git a/internal/config/types.go b/internal/config/types.go index bbf6ead..84acb24 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -77,12 +77,13 @@ type UIConfig struct { // Options: "storm", "drizzle", "monsoon", "rainbow", "synthwave". ColorProfile string `mapstructure:"color_profile" toml:"color_profile"` - // Garden mode tuning (advanced; not surfaced in the settings TUI). - // Any field left at its zero value falls back to the built-in default, - // matching the existing pattern used by RainTickMS. + // 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 probability (0..1) that a new sky drop falls as - // a seed. Lower values mean rarer seedings; default ~0.10. + // 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 @@ -102,7 +103,7 @@ type UIConfig struct { 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 2 and 3). + // 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"` diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index ac64d13..f9266be 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -58,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: @@ -100,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: @@ -121,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 { @@ -130,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: @@ -151,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 @@ -208,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) } @@ -222,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) } @@ -274,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 = ">" diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index f1b8b57..801b17a 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 @@ -55,6 +87,9 @@ type gardenPlot struct { 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. @@ -90,21 +125,21 @@ type GardenTuning struct { // 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 leave 2-3 nearby seeds. +// once per frame, blooms linger, and dying plants scatter few nearby seeds. func DefaultGardenTuning() GardenTuning { return GardenTuning{ - SeedSpawnRate: 0.10, - RainAbsorbExtraChance: 0.15, - PlantedToSproutMoisture: 10, - SproutToBudMoisture: 14, - BudToBloomMoisture: 18, - SeedMoistureBoost: 2, - BloomDurationBase: 60, - BloomDurationJitter: 40, - WitherDuration: 28, - OffspringMin: 2, - OffspringMax: 3, - OffspringSpread: 3, + 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, } } @@ -160,6 +195,110 @@ func ResolveGardenTuning(t GardenTuning) GardenTuning { return t } +// 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 26 + case oftenSeed || fastPace: + return 36 + 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 + } + // 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 type flowerCell struct { drops int // accumulated drop count at this column @@ -230,8 +369,12 @@ func (rb *RainBackground) Reset() { 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) + } } } @@ -250,13 +393,6 @@ func (rb *RainBackground) spawnDrop(minY int) { if rb.Mode == config.UIRainAnimationMatrix { char = matrixGlyphPool[rand.Intn(len(matrixGlyphPool))] } - if rb.Mode == config.UIRainAnimationGarden && !rb.GardenSunny { - if rand.Float64() < rb.Garden.SeedSpawnRate { - isSeed = true - char = "∘" - speed = 2 + rand.Intn(2) // seeds fall a little slower - } - } drop := RainDrop{ X: rand.Intn(rb.Width), Y: startY, @@ -270,6 +406,191 @@ func (rb *RainBackground) spawnDrop(minY int) { 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 rb.GardenPlots == nil || 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) + cap := int(0.5 + float64(lo)+(float64(hi-lo))*relief) + if cap < 1 { + cap = 1 + } + if cap > hi { + cap = hi + } + return cap +} + +// 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 + } + maxSeeds := int(0.5 + float64(rb.Width)*rate*0.20) + 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++ @@ -342,6 +663,9 @@ func (rb *RainBackground) Update() { 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 } @@ -368,8 +692,12 @@ func (rb *RainBackground) Update() { // Spawn new drops to maintain count 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) + } } } @@ -588,6 +916,7 @@ func (rb *RainBackground) gardenAdvancePlots() { g.bloomAge = 0 g.witherAge = 0 g.maxBloom = 0 + g.flowerTint = 0 rb.spawnGardenSeedsBurst(i) } } @@ -725,9 +1054,7 @@ func (rb *RainBackground) paintGardenOverlays(cells []string) { stemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1B5E20")) leafStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#43A047")) - flowerStyle := lipgloss.NewStyle().Foreground(activeProfile().flowerColor) witherStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6D4C41")) - eternalFlower := lipgloss.NewStyle().Foreground(lipgloss.Color("#F8BBD0")).Bold(true) for x := 0; x < rb.Width && x < len(rb.GardenPlots); x++ { g := rb.GardenPlots[x] @@ -744,7 +1071,7 @@ func (rb *RainBackground) paintGardenOverlays(cells []string) { case k == 0: st = stemStyle case k == h-1: - st = eternalFlower + st = gardenFlowerStyle(g.flowerTint).Bold(true) default: st = leafStyle } @@ -755,7 +1082,7 @@ func (rb *RainBackground) paintGardenOverlays(cells []string) { case k == 0 && g.stage >= gardenStageBud: st = stemStyle case k >= h-1 && g.stage >= gardenStageBud: - st = flowerStyle + st = gardenFlowerStyle(g.flowerTint) default: st = leafStyle } diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 81c4973..420d35c 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -62,6 +62,70 @@ func TestRainBackgroundGardenRenderLineWidths(t *testing.T) { } } +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} + } + cap := rb.gardenMaxFlyingSkySeeds(rb.gardenSeedThrottleRelief()) + if cap < 6 { + t.Fatalf("expected mature garden to allow a generous flying-seed cap, got %d", cap) + } + 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 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{}) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index e4d1b53..24328e6 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -255,8 +255,9 @@ func NewRepoSelectorModelStream( } } - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) - rainBg.SetGardenTuning(gardenTuningFromConfig(cfg)) + bgW := resolveRainBackgroundWidth(80) + rainBg := NewRainBackground(bgW, 5, animMode) + rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) return RepoSelectorModel{ repos: nil, @@ -723,38 +724,39 @@ func (m RepoSelectorModel) renderRainWaveStrip(width int) string { // fields from UIConfig into a GardenTuning. Zero values pass through to // ResolveGardenTuning so they fall back to DefaultGardenTuning(). // -// GardenGrowthPace acts as a single user-friendly dial: it scales the three -// stage moisture thresholds together. Per-threshold overrides are intentionally -// not exposed to keep the surface area small; tweak the constants directly if -// you need finer control. -func gardenTuningFromConfig(cfg *config.Config) GardenTuning { - if cfg == nil { - return GardenTuning{} - } - 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 +// 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 } - return s + t.PlantedToSproutMoisture = scale(def.PlantedToSproutMoisture) + t.SproutToBudMoisture = scale(def.SproutToBudMoisture) + t.BudToBloomMoisture = scale(def.BudToBloomMoisture) } - t.PlantedToSproutMoisture = scale(def.PlantedToSproutMoisture) - t.SproutToBudMoisture = scale(def.SproutToBudMoisture) - t.BudToBloomMoisture = scale(def.BudToBloomMoisture) } - return t + resolved := ResolveGardenTuning(t) + applyGardenStormWallClockScale(&resolved, cfg, rainTickMS, gardenWidth) + return resolved } // applyGardenTuning re-applies the model's config-derived garden tuning to @@ -763,7 +765,11 @@ func (m RepoSelectorModel) applyGardenTuning(rb *RainBackground) { if rb == nil { return } - rb.SetGardenTuning(gardenTuningFromConfig(m.cfg)) + tick := m.cfg.UI.RainTickMS + if tick <= 0 { + tick = config.DefaultUIRainTickMS + } + rb.SetGardenTuning(gardenTuningFromConfig(m.cfg, tick, rb.Width)) } // clampCellWidth keeps one screen row within maxCells using lipgloss truncation. From 32dc264ab961e8925237c2628c0514dc4f492bf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 04:16:23 +0000 Subject: [PATCH 5/7] fix(ui): guard nil cfg in applyGardenTuning Co-authored-by: Ben Schellenberger --- internal/ui/repo_selector.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 24328e6..2ce0217 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -765,9 +765,9 @@ func (m RepoSelectorModel) applyGardenTuning(rb *RainBackground) { if rb == nil { return } - tick := m.cfg.UI.RainTickMS - if tick <= 0 { - tick = config.DefaultUIRainTickMS + 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)) } From 112daf024de640d6461a83333872681b5a830dfb Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 12:43:14 -0400 Subject: [PATCH 6/7] fix(ui): honor fast/often/many garden presets vs wall-clock pacing Derive PresetAggression from TUI growth/seed/offspring rows, dampen storm length scaling for aggressive combos, shorten fast+often storm targets, and relax early sky-seed caps and batch coefficients when aggression is high. --- internal/ui/rain_bg.go | 68 +++++++++++++++++++++++++++++++++--- internal/ui/rain_bg_test.go | 30 ++++++++++++++++ internal/ui/repo_selector.go | 1 + 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index ae47d0d..fa64b70 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -121,6 +121,12 @@ type GardenTuning struct { 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 @@ -192,9 +198,40 @@ func ResolveGardenTuning(t GardenTuning) GardenTuning { 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 @@ -218,9 +255,9 @@ func gardenTargetStormWallSeconds(cfg *config.Config) float64 { case calm: return 60 case oftenSeed && fastPace: - return 26 + return 16 case oftenSeed || fastPace: - return 36 + return 28 default: return 32 } @@ -255,6 +292,17 @@ func applyGardenStormWallClockScale(t *GardenTuning, cfg *config.Config, rainTic 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 @@ -536,7 +584,18 @@ func (rb *RainBackground) gardenMaxFlyingSkySeeds(relief float64) int { hi = 4 } lo := max(1, hi/5) - cap := int(0.5 + float64(lo)+(float64(hi-lo))*relief) + ag := rb.Garden.PresetAggression + if ag < 0 { + ag = 0 + } + if ag > 1 { + ag = 1 + } + reliefEff := relief + ag*0.58 + if reliefEff > 1 { + reliefEff = 1 + } + cap := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) if cap < 1 { cap = 1 } @@ -559,7 +618,8 @@ func (rb *RainBackground) gardenSkySeedsForBatch(need int) int { if rate <= 0 { return 0 } - maxSeeds := int(0.5 + float64(rb.Width)*rate*0.20) + coef := 0.20 + rb.Garden.PresetAggression*0.32 + maxSeeds := int(0.5 + float64(rb.Width)*rate*coef) if maxSeeds < 1 { maxSeeds = 1 } diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 7348570..0b4de2e 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -123,6 +123,36 @@ func TestGardenSkySeedsThrottleByMaturityAndFlight(t *testing.T) { } } +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{}) diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 2ce0217..b2d1c9d 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -755,6 +755,7 @@ func gardenTuningFromConfig(cfg *config.Config, rainTickMS, gardenWidth int) Gar } } resolved := ResolveGardenTuning(t) + resolved.PresetAggression = gardenPresetAggression(cfg) applyGardenStormWallClockScale(&resolved, cfg, rainTickMS, gardenWidth) return resolved } From 95062e75a9a1459cd60b8eab66f7125cf0f9849e Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 19 Apr 2026 13:10:48 -0400 Subject: [PATCH 7/7] fix(ui): satisfy golangci-lint and consume garden drops on landing Apply staticcheck/gocritic cleanups (nil len check, De Morgan spawn guard, mode switch in Render, tagged k switches in garden overlays, rename cap). Expire drops after one garden soil interaction so rain and seeds do not re-trigger every frame. Align README garden_seed_rate with defaults and make the mature sky-seed throttle test deterministic. --- README.md | 2 +- internal/ui/rain_bg.go | 57 +++++++++++++++++++++---------------- internal/ui/rain_bg_test.go | 7 +++-- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5fea086..98bfe48 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ in-app settings TUI — leave any unset (or set to `0`) to keep the default. [ui] rain_animation_mode = "garden" -# garden_seed_rate = 0.10 # fraction of new sky drops that fall as seeds (0..1) +# 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 diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index fa64b70..71d4ce4 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -537,7 +537,7 @@ func (rb *RainBackground) gardenSeedsInFlight() int { // 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 rb.GardenPlots == nil || len(rb.GardenPlots) == 0 { + if len(rb.GardenPlots) == 0 { return 0 } var sum float64 @@ -595,14 +595,14 @@ func (rb *RainBackground) gardenMaxFlyingSkySeeds(relief float64) int { if reliefEff > 1 { reliefEff = 1 } - cap := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) - if cap < 1 { - cap = 1 + ceiling := int(0.5 + float64(lo)+(float64(hi-lo))*reliefEff) + if ceiling < 1 { + ceiling = 1 } - if cap > hi { - cap = hi + if ceiling > hi { + ceiling = hi } - return cap + return ceiling } // gardenSkySeedsForBatch returns how many of 'need' new sky drops should be @@ -727,7 +727,7 @@ func (rb *RainBackground) Update() { rb.Flowers[p.X].drops++ } - if rb.Mode == config.UIRainAnimationGarden && p.Y >= maxDropY && rb.GardenPlots != nil && p.X >= 0 && p.X < len(rb.GardenPlots) { + 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 { @@ -749,6 +749,7 @@ func (rb *RainBackground) Update() { rb.gardenWaterPlot(g) } } + p.Age = p.MaxAge } } @@ -762,7 +763,7 @@ func (rb *RainBackground) Update() { rb.Drops = alive // Spawn new drops to maintain count - if rb.Width > 0 && rb.Height > 0 && !(rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny) { + if rb.Width > 0 && rb.Height > 0 && (rb.Mode != config.UIRainAnimationGarden || !rb.GardenSunny) { targetCount := rb.Width * 2 if rb.Mode == config.UIRainAnimationGarden { rb.spawnGardenMaintainingDrops(minY, targetCount) @@ -812,9 +813,10 @@ func (rb *RainBackground) Render() string { } } - if rb.Mode == config.UIRainAnimationGarden { + switch rb.Mode { + case config.UIRainAnimationGarden: rb.paintGardenOverlays(cells) - } else if rb.Mode == config.UIRainAnimationAdvanced { + case config.UIRainAnimationAdvanced: // Top row: clouds if len(rb.CloudRow) >= rb.Width { cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) @@ -833,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(). @@ -1159,22 +1159,31 @@ func (rb *RainBackground) paintGardenOverlays(cells []string) { case gardenStageWither: st = witherStyle case gardenStageEternal: - switch { - case k == 0: + switch k { + case 0: st = stemStyle - case k == h-1: + case h - 1: st = gardenFlowerStyle(g.flowerTint).Bold(true) default: st = leafStyle } default: - switch { - case g.stage == gardenStagePlanted && k == 0: - st = lipgloss.NewStyle().Foreground(lipgloss.Color("#5D4037")) - case k == 0 && g.stage >= gardenStageBud: - st = stemStyle - case k >= h-1 && g.stage >= gardenStageBud: - st = gardenFlowerStyle(g.flowerTint) + 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 } diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 0b4de2e..eac4cfd 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -114,9 +114,10 @@ func TestGardenSkySeedsThrottleByMaturityAndFlight(t *testing.T) { for i := range rb.GardenPlots { rb.GardenPlots[i] = gardenPlot{stage: gardenStageBloom} } - cap := rb.gardenMaxFlyingSkySeeds(rb.gardenSeedThrottleRelief()) - if cap < 6 { - t.Fatalf("expected mature garden to allow a generous flying-seed cap, got %d", cap) + 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)