Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -89,6 +90,7 @@ const (

UIRainAnimationBasic = "basic"
UIRainAnimationAdvanced = "advanced"
UIRainAnimationMatrix = "matrix"
)

// UIColorProfiles returns valid built-in UI color profile names.
Expand Down
1 change: 1 addition & 0 deletions internal/ui/config_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
130 changes: 130 additions & 0 deletions internal/ui/matrix_subliminal.go
Original file line number Diff line number Diff line change
@@ -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)
}
24 changes: 22 additions & 2 deletions internal/ui/panel_layout.go
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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")
}
11 changes: 11 additions & 0 deletions internal/ui/panel_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading
Loading