From c7d018ff207f3aac338bef8d1a5a2755079532e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 16:13:14 +0000 Subject: [PATCH 1/4] 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 e68d8cbdabc2fedd8c413d4b8c94c16ec0253c7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 06:01:40 +0000 Subject: [PATCH 2/4] Matrix mode: weave faint subliminal quote fragments into rain and wave Add ASCII-only marquee phrases (confidence, anti-hero tone, silly dev jokes) shown only in matrix animation: sparse wave glyphs, a rare center-column letter crawl, occasional drop swaps, and a faint mid-field marquee row. Tests assert marquee output stays one terminal cell wide. Co-authored-by: Ben Schellenberger --- internal/ui/matrix_subliminal.go | 130 +++++++++++++++++++++++++++++++ internal/ui/rain_bg.go | 44 ++++++++++- internal/ui/rain_bg_test.go | 22 ++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 internal/ui/matrix_subliminal.go diff --git a/internal/ui/matrix_subliminal.go b/internal/ui/matrix_subliminal.go new file mode 100644 index 0000000..7a713c8 --- /dev/null +++ b/internal/ui/matrix_subliminal.go @@ -0,0 +1,130 @@ +package ui + +import "strings" + +// matrixSubliminalPhrases are short ASCII marquees (one terminal cell per rune) +// woven into matrix mode only — anti-hero energy, confidence, and dumb jokes. +var matrixSubliminalPhrases = []string{ + "THEY CHOSE THE WRONG DAY", + "NOT THE HERO TYPE TODAY", + "STAND IN THE WAY ANYWAY", + "WRONG PERSON WRONG PLAN", + "TRY ME", + "WATCH THIS", + "STILL STANDING", + "ENOUGH", + "RISE ANYWAY", + "NEVER AGAIN", + "ON PURPOSE", + "NO APOLOGY NEEDED", + "NOT FORGOTTEN", + "I REMEMBER", + "JUSTICE IS LOUD", + "BE YOUR OWN BACKUP PLAN", + "FINE I WILL DO IT", + "GIT PUSH FORCE OF WILL", + "MERGE CONFLICT IN MY SOUL", + "IT COMPILED IN SPIRIT", + "COMPILER HATES YOU TOO", + "HYDRATE OR DIEDRATE", + "BE KIND THEN TAKE NAMES", + "PIZZA IS A LIFETIME COMMIT", + "NOT TODAY SATAN", + "CONFIDENCE CLIPPED AT ZERO", + "RUN IT TWICE", + "NO REGRETS ONLY REBASES", +} + +// matrixSubliminalStream is all phrases joined for slow single-column crawls. +var matrixSubliminalStream = buildMatrixSubliminalStream() + +func buildMatrixSubliminalStream() string { + var b strings.Builder + for _, p := range matrixSubliminalPhrases { + if p == "" { + continue + } + b.WriteString(p) + b.WriteByte(' ') + } + return b.String() +} + +// matrixVerticalSubliminalChar shows one letter at a time from the stream +// (matrix wave: occasional faint glyph in a fixed column). +func matrixVerticalSubliminalChar(frame int) (ch string, ok bool) { + s := matrixSubliminalStream + if s == "" { + return "", false + } + const hold = 20 + t := frame / hold + runes := []rune(s) + if len(runes) == 0 { + return "", false + } + for k := 0; k < len(runes); k++ { + r := runes[(t+k)%len(runes)] + if r != ' ' { + return string(r), true + } + } + return "", false +} + +// matrixMarqueeChar returns a single visible character for column x when the +// scrolling phrase covers that cell; otherwise ok is false. +func matrixMarqueeChar(x, frame, width int) (ch string, ok bool) { + if width < 1 || len(matrixSubliminalPhrases) == 0 { + return "", false + } + const gap = 55 + phrase := matrixSubliminalPhrases[frame/matrixMarqueePhraseHoldFrames%len(matrixSubliminalPhrases)] + if len(phrase) == 0 { + return "", false + } + cycle := len(phrase) + width + gap + t := frame % cycle + startCol := width - t + rel := x - startCol + if rel < 0 || rel >= len(phrase) { + return "", false + } + c := phrase[rel] + if c == ' ' { + return "", false + } + return string(c), true +} + +// matrixMarqueePhraseHoldFrames is how many frames each phrase stays before the +// scroll cycle advances to the next line in the list. +const matrixMarqueePhraseHoldFrames = 420 + +// matrixSubliminalBackgroundRow picks at most one row in the rain field for a +// fainter second marquee (different phase so it rarely lines up with the wave). +func matrixSubliminalBackgroundRow(height int) int { + if height < 3 { + return -1 + } + return height / 2 +} + +func matrixMarqueeCharBackground(x, frame, width, height int) (ch string, ok bool) { + row := matrixSubliminalBackgroundRow(height) + if row < 0 { + return "", false + } + // Offset phase so the background line is not synced with the wave strip. + phase := frame + width/2 + row*17 + return matrixMarqueeChar(x, phase, width) +} + +// matrixWaveMaybeSubliminal replaces a wave cell with a faint marquee letter +// when the scroll window covers that column (low frequency via phase primes). +func matrixWaveMaybeSubliminal(x, frame, width int) (ch string, ok bool) { + if (x+frame*3)%11 != 0 && (x+frame)%13 != 0 { + return "", false + } + return matrixMarqueeChar(x, frame+width+29, width) +} diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index 6690672..eb82122 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -108,6 +108,12 @@ func (rb *RainBackground) spawnDrop(minY int) { char := rainDropChars[rand.Intn(len(rainDropChars))] if rb.Mode == config.UIRainAnimationMatrix { char = matrixGlyphPool[rand.Intn(len(matrixGlyphPool))] + // Rarely swap in a subliminal ASCII cell (single-column safe). + if rand.Float64() < 0.04 { + if c, ok := matrixMarqueeChar(rand.Intn(rb.Width), rb.Frame, rb.Width); ok { + char = c + } + } } drop := RainDrop{ X: rand.Intn(rb.Width), @@ -153,6 +159,12 @@ func (rb *RainBackground) Update() { p.X = rb.Width - 1 } + if rb.Mode == config.UIRainAnimationMatrix && rand.Float64() < 0.02 { + if c, ok := matrixMarqueeChar(p.X, rb.Frame, rb.Width); ok { + p.Char = c + } + } + // Color gradient: top (dark) → bottom (bright) progress := float64(p.Y-minY) / float64(rb.Height-minY) if progress < 0 { @@ -245,6 +257,26 @@ func (rb *RainBackground) Render() string { } } + if rb.Mode == config.UIRainAnimationMatrix { + subY := matrixSubliminalBackgroundRow(rb.Height) + if subY >= 0 && subY < rb.Height && len(styles) > 0 { + dim := lipgloss.NewStyle(). + Foreground(matrixRainColors[2]). + Faint(true) + mid := len(styles) / 2 + if mid < 0 { + mid = 0 + } + for x := 0; x < rb.Width; x++ { + if c, ok := matrixMarqueeCharBackground(x, rb.Frame, rb.Width, rb.Height); ok { + cells[subY*rb.Width+x] = dim.Render(c) + } else if rand.Float64() < 0.012 { + cells[subY*rb.Width+x] = styles[mid].Faint(true).Render(matrixGlyphPool[(x+rb.Frame)%len(matrixGlyphPool)]) + } + } + } + } + var result strings.Builder result.Grow(rb.Width*rb.Height*2 + rb.Height) for y := 0; y < rb.Height; y++ { @@ -317,7 +349,17 @@ func RenderRainWave(width, frame int, mode string) string { if colorIdx >= len(styles) { colorIdx = len(styles) - 1 } - result.WriteString(styles[colorIdx].Render(char)) + style := styles[colorIdx] + if c, ok := matrixWaveMaybeSubliminal(x, frame, width); ok { + char = c + style = style.Faint(true) + } else if width > 0 && x == width/2 && frame%47 == 0 { + if c, ok := matrixVerticalSubliminalChar(frame / 47); ok { + char = c + style = style.Faint(true) + } + } + result.WriteString(style.Render(char)) } return result.String() } diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 004e113..eeb89aa 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -33,3 +33,25 @@ func TestRenderRainWaveMatrixWidth(t *testing.T) { t.Fatalf("lipgloss.Width(RenderRainWave matrix) = %d, want %d", got, width) } } + +func TestMatrixMarqueeCharSingleCell(t *testing.T) { + for frame := 0; frame < 2000; frame++ { + for x := 0; x < 80; x++ { + if c, ok := matrixMarqueeChar(x, frame, 80); ok { + if got := lipgloss.Width(c); got != 1 { + t.Fatalf("marquee char width %d at x=%d frame=%d: %q", got, x, frame, c) + } + } + } + } +} + +func TestMatrixVerticalSubliminalCharSingleCell(t *testing.T) { + for frame := 0; frame < 500; frame++ { + if c, ok := matrixVerticalSubliminalChar(frame); ok { + if got := lipgloss.Width(c); got != 1 { + t.Fatalf("vertical subliminal width %d at frame=%d: %q", got, frame, c) + } + } + } +} From cbbf296d89fc67daa62833be5ed57935372ff72b Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sat, 18 Apr 2026 23:15:20 -0400 Subject: [PATCH 3/4] feat(ui): garden rain mode, settings, and growth fixes Add rain_animation_mode garden (advanced-style field) with optional garden_bloom_preset and garden_moisture_cap in config. Show garden settings in the TUI only when mode is garden, directly under rain mode. Wire RainBackground from config, apply bloom thresholds and moisture cap (with cap clamped so full bloom is reachable). Boost fast preset with extra moisture per hit and tighter thresholds. Fix rain simulation only advancing when rainVisible (short terminals froze growth): advance whenever showRain and rainBg are set. Improve flower readability: per-stage colors, garden glyphs, soil tiles for empty columns, safe color gradient denominator. Tests cover moisture accumulation, tight cap full bloom, and fast preset. --- internal/config/defaults.go | 6 +- internal/config/loader.go | 2 + internal/config/types.go | 32 ++++++- internal/ui/config_view.go | 128 +++++++++++++++++++++++++-- internal/ui/rain_bg.go | 167 +++++++++++++++++++++++++++++++---- internal/ui/rain_bg_test.go | 59 ++++++++++++- internal/ui/repo_selector.go | 11 ++- 7 files changed, 371 insertions(+), 34 deletions(-) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index fd3a6a0..734c14a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -114,9 +114,13 @@ mainline_patterns = [] show_rain_animation = true # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), -# or "matrix" (falling code characters) +# "garden" (advanced layout + garden pacing), or "matrix" (falling code characters) rain_animation_mode = "basic" +# Garden-only (ignored unless rain_animation_mode = "garden"): +# garden_bloom_preset = "calm" # calm | normal | fast +# garden_moisture_cap = "off" # off | soft | tight + # Show flavor quotes in the TUI banner show_startup_quote = true diff --git a/internal/config/loader.go b/internal/config/loader.go index 4d8b3aa..3c7546f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -97,6 +97,8 @@ 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) + v.SetDefault("ui.garden_bloom_preset", defaults.UI.GardenBloomPreset) + v.SetDefault("ui.garden_moisture_cap", defaults.UI.GardenMoistureCap) } // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on diff --git a/internal/config/types.go b/internal/config/types.go index ea5d6c3..2295d3f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,6 +1,8 @@ // Package config defines the git-rain configuration schema and related constants. package config +import "strings" + // Config represents the complete git-rain configuration type Config struct { Global GlobalConfig `mapstructure:"global" toml:"global"` @@ -57,9 +59,17 @@ 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). + // "garden" (advanced layout + optional garden pacing), or "matrix" (falling code glyphs). RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"` + // GardenBloomPreset tweaks bottom-row growth thresholds when RainAnimationMode is "garden". + // Values: "calm", "normal", "fast". Empty uses "normal". + GardenBloomPreset string `mapstructure:"garden_bloom_preset" toml:"garden_bloom_preset"` + + // GardenMoistureCap limits rain accumulation per column in garden mode. + // Values: "off", "soft", "tight". Empty uses "off". + GardenMoistureCap string `mapstructure:"garden_moisture_cap" toml:"garden_moisture_cap"` + // Show flavor quotes: TUI banner plus CLI motivation lines. ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"` @@ -91,8 +101,28 @@ const ( UIRainAnimationBasic = "basic" UIRainAnimationAdvanced = "advanced" UIRainAnimationMatrix = "matrix" + UIRainAnimationGarden = "garden" + + UIGardenBloomCalm = "calm" + UIGardenBloomNormal = "normal" + UIGardenBloomFast = "fast" + + UIGardenMoistureOff = "off" + UIGardenMoistureSoft = "soft" + UIGardenMoistureTight = "tight" ) +// UIRainAnimationUsesAdvancedLayout reports whether the mode uses the advanced +// rain field (cloud row, bottom flower row, drop spawn band). +func UIRainAnimationUsesAdvancedLayout(mode string) bool { + switch strings.TrimSpace(strings.ToLower(mode)) { + case UIRainAnimationAdvanced, UIRainAnimationGarden: + return true + default: + return false + } +} + // UIColorProfiles returns valid built-in UI color profile names. func UIColorProfiles() []string { return []string{ diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index a0ff448..d1b445a 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -25,6 +25,102 @@ const ( configRowComingSoon ) +// Garden settings are shown directly under "Rain animation mode" (row index 4). +// Logical indices 0–len(configRows)-1 are the static menu; len(configRows)+ +// offset are the garden-only rows (see logicalRowIndex). +var gardenSettingsConfigRows = []configRow{ + {label: "Garden bloom pace", kind: configRowEnum, options: []string{ + config.UIGardenBloomCalm, + config.UIGardenBloomNormal, + config.UIGardenBloomFast, + }}, + {label: "Garden moisture cap", kind: configRowEnum, options: []string{ + config.UIGardenMoistureOff, + config.UIGardenMoistureSoft, + config.UIGardenMoistureTight, + }}, +} + +func normalizedRainAnimMode(cfg *config.Config) string { + if cfg == nil || strings.TrimSpace(cfg.UI.RainAnimationMode) == "" { + return config.UIRainAnimationBasic + } + return cfg.UI.RainAnimationMode +} + +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 menu index to the legacy row id used in +// configRowValue / applyConfigChange (garden rows use ids len(configRows)..). +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) { + gi := li - len(configRows) + if gi >= 0 && gi < len(gardenSettingsConfigRows) { + return gardenSettingsConfigRows[gi] + } + return configRows[len(configRows)-1] + } + return configRows[li] +} + +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 normalizedGardenBloom(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case config.UIGardenBloomCalm, config.UIGardenBloomNormal, config.UIGardenBloomFast: + return s + default: + return config.UIGardenBloomNormal + } +} + +func normalizedGardenMoisture(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case config.UIGardenMoistureOff, config.UIGardenMoistureSoft, config.UIGardenMoistureTight: + return s + default: + return config.UIGardenMoistureOff + } +} + var configRows = []configRow{ {label: "Default mode", kind: configRowEnum, options: []string{ "sync-default", @@ -41,6 +137,7 @@ var configRows = []configRow{ config.UIRainAnimationBasic, config.UIRainAnimationAdvanced, config.UIRainAnimationMatrix, + config.UIRainAnimationGarden, }}, {label: "Show flavor quotes", kind: configRowBool}, {label: "Flavor quote behavior", kind: configRowEnum, options: []string{ @@ -57,11 +154,11 @@ var configRows = []configRow{ {label: "Custom hex palette", kind: configRowComingSoon}, } -func configRowValue(i int, cfg *config.Config) string { +func configRowValue(visibleI int, cfg *config.Config) string { if cfg == nil { return "" } - switch i { + switch logicalRowIndex(visibleI, cfg) { case 0: return cfg.Global.DefaultMode case 1: @@ -99,18 +196,23 @@ func configRowValue(i int, cfg *config.Config) string { return cfg.UI.ColorProfile case 10: return "coming soon" + case 11: + return normalizedGardenBloom(cfg.UI.GardenBloomPreset) + case 12: + return normalizedGardenMoisture(cfg.UI.GardenMoistureCap) } return "" } -func applyConfigChange(i int, cfg *config.Config, dir int) { +func applyConfigChange(visibleI int, cfg *config.Config, dir int) { if cfg == nil { return } - row := configRows[i] + row := configRowAt(visibleI, cfg) + li := logicalRowIndex(visibleI, cfg) switch row.kind { case configRowBool: - switch i { + switch li { case 1: cfg.Global.DisableScan = !cfg.Global.DisableScan case 3: @@ -120,7 +222,7 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { } case configRowEnum: opts := row.options - cur := configRowValue(i, cfg) + cur := configRowValue(visibleI, cfg) idx := 0 for j, o := range opts { if o == cur { @@ -129,7 +231,7 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { } } idx = (idx + dir + len(opts)) % len(opts) - switch i { + switch li { case 0: cfg.Global.DefaultMode = opts[idx] case 2: @@ -150,6 +252,10 @@ func applyConfigChange(i int, cfg *config.Config, dir int) { applyRainTickChange(cfg, opts, dir) case 9: cfg.UI.ColorProfile = opts[idx] + case 11: + cfg.UI.GardenBloomPreset = opts[idx] + case 12: + cfg.UI.GardenMoistureCap = opts[idx] } case configRowComingSoon: // reserved @@ -207,12 +313,13 @@ func (m RepoSelectorModel) updateConfigView(msg tea.KeyMsg, cmds []tea.Cmd) (tea } case "down", "j": - if m.configCursor < len(configRows)-1 { + if m.configCursor < visibleConfigRowCount(m.cfg)-1 { m.configCursor++ } case " ", "right", "l": applyConfigChange(m.configCursor, m.cfg, +1) + m.configCursor = clampConfigCursor(m.cfg, m.configCursor) if m.cfg != nil { applyColorProfile(m.cfg.UI.ColorProfile) } @@ -221,6 +328,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) } @@ -273,7 +381,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 = ">" @@ -343,6 +452,7 @@ func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorMo m.rainAnimationMode = m.cfg.UI.RainAnimationMode if m.rainBg != nil { m.rainBg.Mode = m.rainAnimationMode + m.rainBg.ApplyGardenFromConfig(m.cfg) } m.showStartupQuote = m.cfg.UI.ShowStartupQuote m.startupQuoteBehavior = m.cfg.UI.StartupQuoteBehavior diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go index eb82122..4639244 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -26,6 +26,10 @@ var cloudChars = [...]string{"☁", "░", "▒", "▓", "█"} // flowerStages for advanced mode bottom row: growth over time var flowerStages = []string{"·", "♦", "✿", "❀"} +// flowerStagesGarden uses a clearer dot → filled disc step before the Unicode +// blooms so terminals that muddy ♦/✿ still read as “opening”. +var flowerStagesGarden = []string{"·", "●", "✿", "❀"} + // RainDrop represents a single falling raindrop particle type RainDrop struct { X int @@ -48,13 +52,17 @@ type RainBackground struct { Height int Drops []RainDrop Frame int - Mode string // "basic" or "advanced" + Mode string // basic | advanced | matrix | garden Flowers []flowerCell CloudRow []string // pre-rendered cloud chars per column + + gardenTh1, gardenTh2, gardenTh3 int // bloom thresholds when Mode == garden + gardenMoistCap int // max accumulated drops per column; 0 = unlimited + gardenMoisturePerHit int // garden only: moisture added per rain hit (fast > 1) } -// NewRainBackground creates a new rain background -func NewRainBackground(width, height int, mode string) *RainBackground { +// NewRainBackground creates a new rain background. cfg may be nil (defaults apply). +func NewRainBackground(width, height int, mode string, cfg *config.Config) *RainBackground { rb := &RainBackground{ Width: width, Height: height, @@ -67,9 +75,102 @@ func NewRainBackground(width, height int, mode string) *RainBackground { rb.CloudRow = rb.buildCloudRow() } rb.Reset() + rb.ApplyGardenFromConfig(cfg) return rb } +// ApplyGardenFromConfig refreshes garden pacing from cfg. Safe when cfg is nil +// or mode is not garden (thresholds reset to advanced defaults; cap cleared). +func (rb *RainBackground) ApplyGardenFromConfig(cfg *config.Config) { + if rb == nil { + return + } + rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 = 3, 8, 15 + rb.gardenMoistCap = 0 + rb.gardenMoisturePerHit = 1 + if cfg == nil || !rainAnimModeIsGarden(rb.Mode) { + return + } + th1, th2, th3 := resolveGardenBloomThresholds(cfg.UI.GardenBloomPreset) + cap := resolveGardenMoistureCap(cfg.UI.GardenMoistureCap) + // A cap below the final bloom threshold would stall growth in the last + // stage forever (drops never reach t3). Keep cap as a ceiling but not + // below what is needed to show the full sequence. + if cap > 0 && cap < th3 { + cap = th3 + } + rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 = th1, th2, th3 + rb.gardenMoistCap = cap + if strings.EqualFold(strings.TrimSpace(cfg.UI.GardenBloomPreset), config.UIGardenBloomFast) { + rb.gardenMoisturePerHit = 3 + } +} + +func rainAnimModeIsGarden(mode string) bool { + return strings.EqualFold(strings.TrimSpace(mode), config.UIRainAnimationGarden) +} + +func flowerGlyph(mode string, stage int) string { + if stage < 0 { + return flowerStages[0] + } + if rainAnimModeIsGarden(mode) { + if stage < len(flowerStagesGarden) { + return flowerStagesGarden[stage] + } + return flowerStagesGarden[len(flowerStagesGarden)-1] + } + if stage < len(flowerStages) { + return flowerStages[stage] + } + return flowerStages[len(flowerStages)-1] +} + +// flowerStyleForGrowthStage makes later bloom stages read clearly against the +// rain palette (same green on every stage looked like endless tiny buds). +func flowerStyleForGrowthStage(stage int) lipgloss.Style { + p := activeProfile() + switch stage { + case 0: + // Slight contrast from the main plant color so “sprout” reads on dark themes. + return lipgloss.NewStyle().Foreground(p.cloudColor) + case 1: + return lipgloss.NewStyle().Foreground(p.flowerColor) + case 2: + return lipgloss.NewStyle().Foreground(p.scrollHint).Bold(true) + case 3: + return lipgloss.NewStyle().Foreground(p.selected).Bold(true) + default: + return lipgloss.NewStyle().Foreground(p.flowerColor) + } +} + +func resolveGardenBloomThresholds(preset string) (int, int, int) { + switch strings.TrimSpace(strings.ToLower(preset)) { + case config.UIGardenBloomCalm: + return 5, 14, 26 + case config.UIGardenBloomFast: + // Few column hits needed; moisture-per-hit is also boosted in ApplyGarden. + return 1, 2, 4 + case config.UIGardenBloomNormal: + return 2, 6, 12 + default: + // Empty / unknown preset: match “normal” pacing for garden + return 2, 6, 12 + } +} + +func resolveGardenMoistureCap(s string) int { + switch strings.TrimSpace(strings.ToLower(s)) { + case config.UIGardenMoistureSoft: + return 20 + case config.UIGardenMoistureTight: + return 12 + default: + return 0 + } +} + func (rb *RainBackground) buildCloudRow() []string { row := make([]string, rb.Width) for x := 0; x < rb.Width; x++ { @@ -86,7 +187,7 @@ func (rb *RainBackground) buildCloudRow() []string { func (rb *RainBackground) Reset() { rb.Drops = make([]RainDrop, 0) startY := 0 - if rb.Mode == config.UIRainAnimationAdvanced { + if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { startY = 1 // leave top row for clouds } targetCount := rb.Width * 2 @@ -133,7 +234,7 @@ func (rb *RainBackground) Update() { minY := 0 maxDropY := rb.Height - 1 - if rb.Mode == config.UIRainAnimationAdvanced { + if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { minY = 1 maxDropY = rb.Height - 2 // leave bottom row for flowers } @@ -166,7 +267,11 @@ func (rb *RainBackground) Update() { } // Color gradient: top (dark) → bottom (bright) - progress := float64(p.Y-minY) / float64(rb.Height-minY) + denom := rb.Height - minY + if denom < 1 { + denom = 1 + } + progress := float64(p.Y-minY) / float64(denom) if progress < 0 { progress = 0 } @@ -179,9 +284,24 @@ func (rb *RainBackground) Update() { p.ColorIdx = paletteLen - 1 } - // In advanced mode, accumulate flowers when a drop reaches the bottom - if rb.Mode == config.UIRainAnimationAdvanced && p.Y >= maxDropY && rb.Flowers != nil && p.X < len(rb.Flowers) { - rb.Flowers[p.X].drops++ + // In advanced / garden mode, accumulate flowers when a drop reaches the bottom + if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) && p.Y >= maxDropY && rb.Flowers != nil && p.X < len(rb.Flowers) { + atCap := rainAnimModeIsGarden(rb.Mode) && rb.gardenMoistCap > 0 && rb.Flowers[p.X].drops >= rb.gardenMoistCap + if !atCap { + inc := 1 + if rainAnimModeIsGarden(rb.Mode) && rb.gardenMoisturePerHit > 1 { + inc = rb.gardenMoisturePerHit + } + if rainAnimModeIsGarden(rb.Mode) && rb.gardenMoistCap > 0 { + room := rb.gardenMoistCap - rb.Flowers[p.X].drops + if room < inc { + inc = room + } + } + if inc > 0 { + rb.Flowers[p.X].drops += inc + } + } } } @@ -202,8 +322,8 @@ func (rb *RainBackground) Update() { } } - // 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 mode + if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) && rb.Frame%30 == 0 && rb.Width > 0 { rb.CloudRow = rb.buildCloudRow() } } @@ -236,7 +356,7 @@ func (rb *RainBackground) Render() string { } } - if rb.Mode == config.UIRainAnimationAdvanced { + if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { // Top row: clouds if len(rb.CloudRow) >= rb.Width { cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) @@ -250,8 +370,11 @@ func (rb *RainBackground) Render() string { for x := 0; x < rb.Width && x < len(rb.Flowers); x++ { stage := rb.flowerStage(x) if stage >= 0 { - flowerStyle := lipgloss.NewStyle().Foreground(activeProfile().flowerColor) - cells[bottomY*rb.Width+x] = flowerStyle.Render(flowerStages[stage]) + ch := flowerGlyph(rb.Mode, stage) + cells[bottomY*rb.Width+x] = flowerStyleForGrowthStage(stage).Render(ch) + } else if rainAnimModeIsGarden(rb.Mode) { + soil := lipgloss.NewStyle().Foreground(activeProfile().configDim) + cells[bottomY*rb.Width+x] = soil.Render("░") } } } @@ -296,20 +419,28 @@ func (rb *RainBackground) flowerStage(x int) int { return -1 } drops := rb.Flowers[x].drops + t1, t2, t3 := rb.flowerThresholds() switch { case drops == 0: return -1 - case drops < 3: + case drops < t1: return 0 - case drops < 8: + case drops < t2: return 1 - case drops < 15: + case drops < t3: return 2 default: return 3 } } +func (rb *RainBackground) flowerThresholds() (t1, t2, t3 int) { + if rainAnimModeIsGarden(rb.Mode) { + return rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 + } + return 3, 8, 15 +} + // 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 { @@ -319,7 +450,7 @@ func RenderRainWave(width, frame int, mode string) string { return strings.Repeat("~", width) } - if mode == config.UIRainAnimationAdvanced { + if config.UIRainAnimationUsesAdvancedLayout(mode) { // Render a cloud band across the full width cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) for x := 0; x < width; x++ { diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index eeb89aa..57a06a4 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -10,7 +10,7 @@ import ( func TestRainBackgroundMatrixRenderLineWidths(t *testing.T) { const w, h = 24, 5 - rb := NewRainBackground(w, h, config.UIRainAnimationMatrix) + rb := NewRainBackground(w, h, config.UIRainAnimationMatrix, nil) for i := 0; i < 20; i++ { rb.Update() } @@ -55,3 +55,60 @@ func TestMatrixVerticalSubliminalCharSingleCell(t *testing.T) { } } } + +func TestRainBackgroundAdvancedAccumulatesFlowerMoisture(t *testing.T) { + rb := NewRainBackground(40, 5, config.UIRainAnimationAdvanced, nil) + for i := 0; i < 400; i++ { + rb.Update() + } + maxD := 0 + for _, f := range rb.Flowers { + if f.drops > maxD { + maxD = f.drops + } + } + if maxD < 10 { + t.Fatalf("expected substantial moisture accumulation, got max drops=%d", maxD) + } +} + +func TestRainBackgroundGardenFastPresetReachesFullBloomQuickly(t *testing.T) { + cfg := config.DefaultConfig() + cfg.UI.RainAnimationMode = config.UIRainAnimationGarden + cfg.UI.GardenBloomPreset = config.UIGardenBloomFast + rb := NewRainBackground(24, 5, config.UIRainAnimationGarden, &cfg) + for i := 0; i < 120; i++ { + rb.Update() + } + found := false + for x := 0; x < rb.Width; x++ { + if rb.flowerStage(x) == 3 { + found = true + break + } + } + if !found { + t.Fatal("expected full bloom on fast preset within 120 frames") + } +} + +func TestRainBackgroundGardenTightMoistureCapReachesFullBloom(t *testing.T) { + cfg := config.DefaultConfig() + cfg.UI.RainAnimationMode = config.UIRainAnimationGarden + cfg.UI.GardenBloomPreset = config.UIGardenBloomNormal + cfg.UI.GardenMoistureCap = config.UIGardenMoistureTight + rb := NewRainBackground(40, 5, config.UIRainAnimationGarden, &cfg) + for i := 0; i < 800; i++ { + rb.Update() + } + found := false + for x := 0; x < rb.Width; x++ { + if rb.flowerStage(x) == 3 { + found = true + break + } + } + if !found { + t.Fatal("expected at least one column to reach full bloom with tight moisture cap") + } +} diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 7aa7b68..b6caf93 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -186,7 +186,7 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder) animMode := config.UIRainAnimationBasic - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode, nil) return RepoSelectorModel{ repos: repos, @@ -255,7 +255,7 @@ func NewRepoSelectorModelStream( } } - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode, cfg) return RepoSelectorModel{ repos: nil, @@ -336,14 +336,17 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.windowWidth = msg.Width m.windowHeight = msg.Height bgW := resolveRainBackgroundWidth(msg.Width) - m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode) + m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode, m.cfg) 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)) case tickMsg: m.frameIndex = (m.frameIndex + 1) % len(rainFrames) - if m.rainVisible() { + // Advance rain whenever it is enabled — rainVisible() only controls whether + // the layout has room to paint it; freezing Update() made growth look dead + // on shorter terminals or when the header was temporarily hidden. + if m.showRain && m.rainBg != nil { m.rainBg.Update() } return m, tickCmd(m.rainTick) From ac9f14e1cd4d7a6d0ed6353a4d12a449dd0a3b69 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sat, 18 Apr 2026 23:23:24 -0400 Subject: [PATCH 4/4] Revert "feat(ui): garden rain mode, settings, and growth fixes" This reverts commit cbbf296d89fc67daa62833be5ed57935372ff72b. --- internal/config/defaults.go | 6 +- internal/config/loader.go | 2 - internal/config/types.go | 32 +------ internal/ui/config_view.go | 128 ++------------------------- internal/ui/rain_bg.go | 167 ++++------------------------------- internal/ui/rain_bg_test.go | 59 +------------ internal/ui/repo_selector.go | 11 +-- 7 files changed, 34 insertions(+), 371 deletions(-) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 734c14a..fd3a6a0 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -114,13 +114,9 @@ mainline_patterns = [] show_rain_animation = true # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), -# "garden" (advanced layout + garden pacing), or "matrix" (falling code characters) +# or "matrix" (falling code characters) rain_animation_mode = "basic" -# Garden-only (ignored unless rain_animation_mode = "garden"): -# garden_bloom_preset = "calm" # calm | normal | fast -# garden_moisture_cap = "off" # off | soft | tight - # Show flavor quotes in the TUI banner show_startup_quote = true diff --git a/internal/config/loader.go b/internal/config/loader.go index 3c7546f..4d8b3aa 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -97,8 +97,6 @@ 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) - v.SetDefault("ui.garden_bloom_preset", defaults.UI.GardenBloomPreset) - v.SetDefault("ui.garden_moisture_cap", defaults.UI.GardenMoistureCap) } // 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 2295d3f..ea5d6c3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,8 +1,6 @@ // Package config defines the git-rain configuration schema and related constants. package config -import "strings" - // Config represents the complete git-rain configuration type Config struct { Global GlobalConfig `mapstructure:"global" toml:"global"` @@ -59,17 +57,9 @@ type UIConfig struct { ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"` // Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers), - // "garden" (advanced layout + optional garden pacing), or "matrix" (falling code glyphs). + // or "matrix" (falling code glyphs in the same column pattern). RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"` - // GardenBloomPreset tweaks bottom-row growth thresholds when RainAnimationMode is "garden". - // Values: "calm", "normal", "fast". Empty uses "normal". - GardenBloomPreset string `mapstructure:"garden_bloom_preset" toml:"garden_bloom_preset"` - - // GardenMoistureCap limits rain accumulation per column in garden mode. - // Values: "off", "soft", "tight". Empty uses "off". - GardenMoistureCap string `mapstructure:"garden_moisture_cap" toml:"garden_moisture_cap"` - // Show flavor quotes: TUI banner plus CLI motivation lines. ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"` @@ -101,28 +91,8 @@ const ( UIRainAnimationBasic = "basic" UIRainAnimationAdvanced = "advanced" UIRainAnimationMatrix = "matrix" - UIRainAnimationGarden = "garden" - - UIGardenBloomCalm = "calm" - UIGardenBloomNormal = "normal" - UIGardenBloomFast = "fast" - - UIGardenMoistureOff = "off" - UIGardenMoistureSoft = "soft" - UIGardenMoistureTight = "tight" ) -// UIRainAnimationUsesAdvancedLayout reports whether the mode uses the advanced -// rain field (cloud row, bottom flower row, drop spawn band). -func UIRainAnimationUsesAdvancedLayout(mode string) bool { - switch strings.TrimSpace(strings.ToLower(mode)) { - case UIRainAnimationAdvanced, UIRainAnimationGarden: - return true - default: - return false - } -} - // UIColorProfiles returns valid built-in UI color profile names. func UIColorProfiles() []string { return []string{ diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index d1b445a..a0ff448 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -25,102 +25,6 @@ const ( configRowComingSoon ) -// Garden settings are shown directly under "Rain animation mode" (row index 4). -// Logical indices 0–len(configRows)-1 are the static menu; len(configRows)+ -// offset are the garden-only rows (see logicalRowIndex). -var gardenSettingsConfigRows = []configRow{ - {label: "Garden bloom pace", kind: configRowEnum, options: []string{ - config.UIGardenBloomCalm, - config.UIGardenBloomNormal, - config.UIGardenBloomFast, - }}, - {label: "Garden moisture cap", kind: configRowEnum, options: []string{ - config.UIGardenMoistureOff, - config.UIGardenMoistureSoft, - config.UIGardenMoistureTight, - }}, -} - -func normalizedRainAnimMode(cfg *config.Config) string { - if cfg == nil || strings.TrimSpace(cfg.UI.RainAnimationMode) == "" { - return config.UIRainAnimationBasic - } - return cfg.UI.RainAnimationMode -} - -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 menu index to the legacy row id used in -// configRowValue / applyConfigChange (garden rows use ids len(configRows)..). -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) { - gi := li - len(configRows) - if gi >= 0 && gi < len(gardenSettingsConfigRows) { - return gardenSettingsConfigRows[gi] - } - return configRows[len(configRows)-1] - } - return configRows[li] -} - -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 normalizedGardenBloom(s string) string { - s = strings.TrimSpace(strings.ToLower(s)) - switch s { - case config.UIGardenBloomCalm, config.UIGardenBloomNormal, config.UIGardenBloomFast: - return s - default: - return config.UIGardenBloomNormal - } -} - -func normalizedGardenMoisture(s string) string { - s = strings.TrimSpace(strings.ToLower(s)) - switch s { - case config.UIGardenMoistureOff, config.UIGardenMoistureSoft, config.UIGardenMoistureTight: - return s - default: - return config.UIGardenMoistureOff - } -} - var configRows = []configRow{ {label: "Default mode", kind: configRowEnum, options: []string{ "sync-default", @@ -137,7 +41,6 @@ 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{ @@ -154,11 +57,11 @@ var configRows = []configRow{ {label: "Custom hex palette", kind: configRowComingSoon}, } -func configRowValue(visibleI int, cfg *config.Config) string { +func configRowValue(i int, cfg *config.Config) string { if cfg == nil { return "" } - switch logicalRowIndex(visibleI, cfg) { + switch i { case 0: return cfg.Global.DefaultMode case 1: @@ -196,23 +99,18 @@ func configRowValue(visibleI int, cfg *config.Config) string { return cfg.UI.ColorProfile case 10: return "coming soon" - case 11: - return normalizedGardenBloom(cfg.UI.GardenBloomPreset) - case 12: - return normalizedGardenMoisture(cfg.UI.GardenMoistureCap) } return "" } -func applyConfigChange(visibleI int, cfg *config.Config, dir int) { +func applyConfigChange(i int, cfg *config.Config, dir int) { if cfg == nil { return } - row := configRowAt(visibleI, cfg) - li := logicalRowIndex(visibleI, cfg) + row := configRows[i] switch row.kind { case configRowBool: - switch li { + switch i { case 1: cfg.Global.DisableScan = !cfg.Global.DisableScan case 3: @@ -222,7 +120,7 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { } case configRowEnum: opts := row.options - cur := configRowValue(visibleI, cfg) + cur := configRowValue(i, cfg) idx := 0 for j, o := range opts { if o == cur { @@ -231,7 +129,7 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { } } idx = (idx + dir + len(opts)) % len(opts) - switch li { + switch i { case 0: cfg.Global.DefaultMode = opts[idx] case 2: @@ -252,10 +150,6 @@ func applyConfigChange(visibleI int, cfg *config.Config, dir int) { applyRainTickChange(cfg, opts, dir) case 9: cfg.UI.ColorProfile = opts[idx] - case 11: - cfg.UI.GardenBloomPreset = opts[idx] - case 12: - cfg.UI.GardenMoistureCap = opts[idx] } case configRowComingSoon: // reserved @@ -313,13 +207,12 @@ func (m RepoSelectorModel) updateConfigView(msg tea.KeyMsg, cmds []tea.Cmd) (tea } case "down", "j": - if m.configCursor < visibleConfigRowCount(m.cfg)-1 { + if m.configCursor < len(configRows)-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) } @@ -328,7 +221,6 @@ 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) } @@ -381,8 +273,7 @@ func (m RepoSelectorModel) viewConfig() string { valueStyle := lipgloss.NewStyle().Foreground(activeProfile().configValue).Bold(true) dimStyle := lipgloss.NewStyle().Foreground(activeProfile().configDim) - for i := 0; i < visibleConfigRowCount(m.cfg); i++ { - row := configRowAt(i, m.cfg) + for i, row := range configRows { cur := " " if m.configCursor == i { cur = ">" @@ -452,7 +343,6 @@ func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorMo m.rainAnimationMode = m.cfg.UI.RainAnimationMode if m.rainBg != nil { m.rainBg.Mode = m.rainAnimationMode - m.rainBg.ApplyGardenFromConfig(m.cfg) } 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 4639244..eb82122 100644 --- a/internal/ui/rain_bg.go +++ b/internal/ui/rain_bg.go @@ -26,10 +26,6 @@ var cloudChars = [...]string{"☁", "░", "▒", "▓", "█"} // flowerStages for advanced mode bottom row: growth over time var flowerStages = []string{"·", "♦", "✿", "❀"} -// flowerStagesGarden uses a clearer dot → filled disc step before the Unicode -// blooms so terminals that muddy ♦/✿ still read as “opening”. -var flowerStagesGarden = []string{"·", "●", "✿", "❀"} - // RainDrop represents a single falling raindrop particle type RainDrop struct { X int @@ -52,17 +48,13 @@ type RainBackground struct { Height int Drops []RainDrop Frame int - Mode string // basic | advanced | matrix | garden + Mode string // "basic" or "advanced" Flowers []flowerCell CloudRow []string // pre-rendered cloud chars per column - - gardenTh1, gardenTh2, gardenTh3 int // bloom thresholds when Mode == garden - gardenMoistCap int // max accumulated drops per column; 0 = unlimited - gardenMoisturePerHit int // garden only: moisture added per rain hit (fast > 1) } -// NewRainBackground creates a new rain background. cfg may be nil (defaults apply). -func NewRainBackground(width, height int, mode string, cfg *config.Config) *RainBackground { +// NewRainBackground creates a new rain background +func NewRainBackground(width, height int, mode string) *RainBackground { rb := &RainBackground{ Width: width, Height: height, @@ -75,102 +67,9 @@ func NewRainBackground(width, height int, mode string, cfg *config.Config) *Rain rb.CloudRow = rb.buildCloudRow() } rb.Reset() - rb.ApplyGardenFromConfig(cfg) return rb } -// ApplyGardenFromConfig refreshes garden pacing from cfg. Safe when cfg is nil -// or mode is not garden (thresholds reset to advanced defaults; cap cleared). -func (rb *RainBackground) ApplyGardenFromConfig(cfg *config.Config) { - if rb == nil { - return - } - rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 = 3, 8, 15 - rb.gardenMoistCap = 0 - rb.gardenMoisturePerHit = 1 - if cfg == nil || !rainAnimModeIsGarden(rb.Mode) { - return - } - th1, th2, th3 := resolveGardenBloomThresholds(cfg.UI.GardenBloomPreset) - cap := resolveGardenMoistureCap(cfg.UI.GardenMoistureCap) - // A cap below the final bloom threshold would stall growth in the last - // stage forever (drops never reach t3). Keep cap as a ceiling but not - // below what is needed to show the full sequence. - if cap > 0 && cap < th3 { - cap = th3 - } - rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 = th1, th2, th3 - rb.gardenMoistCap = cap - if strings.EqualFold(strings.TrimSpace(cfg.UI.GardenBloomPreset), config.UIGardenBloomFast) { - rb.gardenMoisturePerHit = 3 - } -} - -func rainAnimModeIsGarden(mode string) bool { - return strings.EqualFold(strings.TrimSpace(mode), config.UIRainAnimationGarden) -} - -func flowerGlyph(mode string, stage int) string { - if stage < 0 { - return flowerStages[0] - } - if rainAnimModeIsGarden(mode) { - if stage < len(flowerStagesGarden) { - return flowerStagesGarden[stage] - } - return flowerStagesGarden[len(flowerStagesGarden)-1] - } - if stage < len(flowerStages) { - return flowerStages[stage] - } - return flowerStages[len(flowerStages)-1] -} - -// flowerStyleForGrowthStage makes later bloom stages read clearly against the -// rain palette (same green on every stage looked like endless tiny buds). -func flowerStyleForGrowthStage(stage int) lipgloss.Style { - p := activeProfile() - switch stage { - case 0: - // Slight contrast from the main plant color so “sprout” reads on dark themes. - return lipgloss.NewStyle().Foreground(p.cloudColor) - case 1: - return lipgloss.NewStyle().Foreground(p.flowerColor) - case 2: - return lipgloss.NewStyle().Foreground(p.scrollHint).Bold(true) - case 3: - return lipgloss.NewStyle().Foreground(p.selected).Bold(true) - default: - return lipgloss.NewStyle().Foreground(p.flowerColor) - } -} - -func resolveGardenBloomThresholds(preset string) (int, int, int) { - switch strings.TrimSpace(strings.ToLower(preset)) { - case config.UIGardenBloomCalm: - return 5, 14, 26 - case config.UIGardenBloomFast: - // Few column hits needed; moisture-per-hit is also boosted in ApplyGarden. - return 1, 2, 4 - case config.UIGardenBloomNormal: - return 2, 6, 12 - default: - // Empty / unknown preset: match “normal” pacing for garden - return 2, 6, 12 - } -} - -func resolveGardenMoistureCap(s string) int { - switch strings.TrimSpace(strings.ToLower(s)) { - case config.UIGardenMoistureSoft: - return 20 - case config.UIGardenMoistureTight: - return 12 - default: - return 0 - } -} - func (rb *RainBackground) buildCloudRow() []string { row := make([]string, rb.Width) for x := 0; x < rb.Width; x++ { @@ -187,7 +86,7 @@ func (rb *RainBackground) buildCloudRow() []string { func (rb *RainBackground) Reset() { rb.Drops = make([]RainDrop, 0) startY := 0 - if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { + if rb.Mode == config.UIRainAnimationAdvanced { startY = 1 // leave top row for clouds } targetCount := rb.Width * 2 @@ -234,7 +133,7 @@ func (rb *RainBackground) Update() { minY := 0 maxDropY := rb.Height - 1 - if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { + if rb.Mode == config.UIRainAnimationAdvanced { minY = 1 maxDropY = rb.Height - 2 // leave bottom row for flowers } @@ -267,11 +166,7 @@ func (rb *RainBackground) Update() { } // Color gradient: top (dark) → bottom (bright) - denom := rb.Height - minY - if denom < 1 { - denom = 1 - } - progress := float64(p.Y-minY) / float64(denom) + progress := float64(p.Y-minY) / float64(rb.Height-minY) if progress < 0 { progress = 0 } @@ -284,24 +179,9 @@ func (rb *RainBackground) Update() { p.ColorIdx = paletteLen - 1 } - // In advanced / garden mode, accumulate flowers when a drop reaches the bottom - if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) && p.Y >= maxDropY && rb.Flowers != nil && p.X < len(rb.Flowers) { - atCap := rainAnimModeIsGarden(rb.Mode) && rb.gardenMoistCap > 0 && rb.Flowers[p.X].drops >= rb.gardenMoistCap - if !atCap { - inc := 1 - if rainAnimModeIsGarden(rb.Mode) && rb.gardenMoisturePerHit > 1 { - inc = rb.gardenMoisturePerHit - } - if rainAnimModeIsGarden(rb.Mode) && rb.gardenMoistCap > 0 { - room := rb.gardenMoistCap - rb.Flowers[p.X].drops - if room < inc { - inc = room - } - } - if inc > 0 { - rb.Flowers[p.X].drops += inc - } - } + // In advanced mode, accumulate flowers when a drop reaches the bottom + if rb.Mode == config.UIRainAnimationAdvanced && p.Y >= maxDropY && rb.Flowers != nil && p.X < len(rb.Flowers) { + rb.Flowers[p.X].drops++ } } @@ -322,8 +202,8 @@ func (rb *RainBackground) Update() { } } - // Periodically refresh cloud row in advanced / garden mode - if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) && rb.Frame%30 == 0 && rb.Width > 0 { + // Periodically refresh cloud row in advanced mode + if rb.Mode == config.UIRainAnimationAdvanced && rb.Frame%30 == 0 && rb.Width > 0 { rb.CloudRow = rb.buildCloudRow() } } @@ -356,7 +236,7 @@ func (rb *RainBackground) Render() string { } } - if config.UIRainAnimationUsesAdvancedLayout(rb.Mode) { + if rb.Mode == config.UIRainAnimationAdvanced { // Top row: clouds if len(rb.CloudRow) >= rb.Width { cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) @@ -370,11 +250,8 @@ func (rb *RainBackground) Render() string { for x := 0; x < rb.Width && x < len(rb.Flowers); x++ { stage := rb.flowerStage(x) if stage >= 0 { - ch := flowerGlyph(rb.Mode, stage) - cells[bottomY*rb.Width+x] = flowerStyleForGrowthStage(stage).Render(ch) - } else if rainAnimModeIsGarden(rb.Mode) { - soil := lipgloss.NewStyle().Foreground(activeProfile().configDim) - cells[bottomY*rb.Width+x] = soil.Render("░") + flowerStyle := lipgloss.NewStyle().Foreground(activeProfile().flowerColor) + cells[bottomY*rb.Width+x] = flowerStyle.Render(flowerStages[stage]) } } } @@ -419,28 +296,20 @@ func (rb *RainBackground) flowerStage(x int) int { return -1 } drops := rb.Flowers[x].drops - t1, t2, t3 := rb.flowerThresholds() switch { case drops == 0: return -1 - case drops < t1: + case drops < 3: return 0 - case drops < t2: + case drops < 8: return 1 - case drops < t3: + case drops < 15: return 2 default: return 3 } } -func (rb *RainBackground) flowerThresholds() (t1, t2, t3 int) { - if rainAnimModeIsGarden(rb.Mode) { - return rb.gardenTh1, rb.gardenTh2, rb.gardenTh3 - } - return 3, 8, 15 -} - // 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 { @@ -450,7 +319,7 @@ func RenderRainWave(width, frame int, mode string) string { return strings.Repeat("~", width) } - if config.UIRainAnimationUsesAdvancedLayout(mode) { + if mode == config.UIRainAnimationAdvanced { // Render a cloud band across the full width cloudStyle := lipgloss.NewStyle().Foreground(activeProfile().cloudColor) for x := 0; x < width; x++ { diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go index 57a06a4..eeb89aa 100644 --- a/internal/ui/rain_bg_test.go +++ b/internal/ui/rain_bg_test.go @@ -10,7 +10,7 @@ import ( func TestRainBackgroundMatrixRenderLineWidths(t *testing.T) { const w, h = 24, 5 - rb := NewRainBackground(w, h, config.UIRainAnimationMatrix, nil) + rb := NewRainBackground(w, h, config.UIRainAnimationMatrix) for i := 0; i < 20; i++ { rb.Update() } @@ -55,60 +55,3 @@ func TestMatrixVerticalSubliminalCharSingleCell(t *testing.T) { } } } - -func TestRainBackgroundAdvancedAccumulatesFlowerMoisture(t *testing.T) { - rb := NewRainBackground(40, 5, config.UIRainAnimationAdvanced, nil) - for i := 0; i < 400; i++ { - rb.Update() - } - maxD := 0 - for _, f := range rb.Flowers { - if f.drops > maxD { - maxD = f.drops - } - } - if maxD < 10 { - t.Fatalf("expected substantial moisture accumulation, got max drops=%d", maxD) - } -} - -func TestRainBackgroundGardenFastPresetReachesFullBloomQuickly(t *testing.T) { - cfg := config.DefaultConfig() - cfg.UI.RainAnimationMode = config.UIRainAnimationGarden - cfg.UI.GardenBloomPreset = config.UIGardenBloomFast - rb := NewRainBackground(24, 5, config.UIRainAnimationGarden, &cfg) - for i := 0; i < 120; i++ { - rb.Update() - } - found := false - for x := 0; x < rb.Width; x++ { - if rb.flowerStage(x) == 3 { - found = true - break - } - } - if !found { - t.Fatal("expected full bloom on fast preset within 120 frames") - } -} - -func TestRainBackgroundGardenTightMoistureCapReachesFullBloom(t *testing.T) { - cfg := config.DefaultConfig() - cfg.UI.RainAnimationMode = config.UIRainAnimationGarden - cfg.UI.GardenBloomPreset = config.UIGardenBloomNormal - cfg.UI.GardenMoistureCap = config.UIGardenMoistureTight - rb := NewRainBackground(40, 5, config.UIRainAnimationGarden, &cfg) - for i := 0; i < 800; i++ { - rb.Update() - } - found := false - for x := 0; x < rb.Width; x++ { - if rb.flowerStage(x) == 3 { - found = true - break - } - } - if !found { - t.Fatal("expected at least one column to reach full bloom with tight moisture cap") - } -} diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index b6caf93..7aa7b68 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -186,7 +186,7 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder) animMode := config.UIRainAnimationBasic - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode, nil) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) return RepoSelectorModel{ repos: repos, @@ -255,7 +255,7 @@ func NewRepoSelectorModelStream( } } - rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode, cfg) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) return RepoSelectorModel{ repos: nil, @@ -336,17 +336,14 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.windowWidth = msg.Width m.windowHeight = msg.Height bgW := resolveRainBackgroundWidth(msg.Width) - m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode, m.cfg) + m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode) 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)) case tickMsg: m.frameIndex = (m.frameIndex + 1) % len(rainFrames) - // Advance rain whenever it is enabled — rainVisible() only controls whether - // the layout has room to paint it; freezing Update() made growth look dead - // on shorter terminals or when the header was temporarily hidden. - if m.showRain && m.rainBg != nil { + if m.rainVisible() { m.rainBg.Update() } return m, tickCmd(m.rainTick)