Skip to content

Commit 202034b

Browse files
feat(tui): make completion screen confetti continuous and responsive
Confetti particles now respawn at the top when they expire instead of being removed, creating a continuous rain effect. The overlay compositor uses ANSI-aware string handling to prevent styled confetti characters from corrupting the modal layout. Confetti bounds update on terminal resize so particles always cover the full screen.
1 parent eb12565 commit 202034b

2 files changed

Lines changed: 88 additions & 36 deletions

File tree

internal/tui/completion.go

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,24 @@ func (c *CompletionScreen) Configure(prdName string, completed, total int, branc
7777
c.prURL = ""
7878
c.prTitle = ""
7979
c.spinnerFrame = 0
80-
// Initialize confetti
81-
c.confetti = NewConfetti(c.width, c.height)
80+
// Initialize confetti (deferred until SetSize if dimensions aren't known yet)
81+
if c.width > 0 && c.height > 0 {
82+
c.confetti = NewConfetti(c.width, c.height)
83+
} else {
84+
c.confetti = nil
85+
}
8286
}
8387

8488
// SetSize sets the screen dimensions.
8589
func (c *CompletionScreen) SetSize(width, height int) {
8690
c.width = width
8791
c.height = height
92+
if c.confetti != nil {
93+
c.confetti.SetSize(width, height)
94+
} else if c.prdName != "" && width > 0 && height > 0 {
95+
// Initialize confetti now that we have real dimensions
96+
c.confetti = NewConfetti(width, height)
97+
}
8898
}
8999

90100
// PRDName returns the PRD name shown on the completion screen.
@@ -459,6 +469,60 @@ func formatPRDTitle(name string) string {
459469
return strings.Join(words, " ")
460470
}
461471

472+
// ansiTruncate returns the first maxWidth visual columns of an ANSI-styled string,
473+
// properly passing through escape sequences without counting them as visible width.
474+
func ansiTruncate(s string, maxWidth int) string {
475+
var result strings.Builder
476+
width := 0
477+
inEscape := false
478+
for _, r := range s {
479+
if r == '\033' {
480+
inEscape = true
481+
result.WriteRune(r)
482+
continue
483+
}
484+
if inEscape {
485+
result.WriteRune(r)
486+
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
487+
inEscape = false
488+
}
489+
continue
490+
}
491+
if width >= maxWidth {
492+
break
493+
}
494+
result.WriteRune(r)
495+
width++
496+
}
497+
// Reset any open ANSI styling
498+
result.WriteString("\033[0m")
499+
return result.String()
500+
}
501+
502+
// ansiSkip skips the first skipWidth visual columns of an ANSI-styled string
503+
// and returns the remainder.
504+
func ansiSkip(s string, skipWidth int) string {
505+
width := 0
506+
inEscape := false
507+
for i, r := range s {
508+
if r == '\033' {
509+
inEscape = true
510+
continue
511+
}
512+
if inEscape {
513+
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
514+
inEscape = false
515+
}
516+
continue
517+
}
518+
if width >= skipWidth {
519+
return s[i:]
520+
}
521+
width++
522+
}
523+
return ""
524+
}
525+
462526
// overlayModal composites a modal on top of a background, centering the modal.
463527
func overlayModal(background, modal string, screenWidth, screenHeight int) string {
464528
bgLines := strings.Split(background, "\n")
@@ -501,38 +565,13 @@ func overlayModal(background, modal string, screenWidth, screenHeight int) strin
501565
continue
502566
}
503567

504-
// Build the composited line: bg prefix + modal + bg suffix
505568
bgLine := bgLines[bgIdx]
506-
bgRunes := []rune(bgLine)
507-
508-
// Pad bg line if needed
509-
bgVisualWidth := lipgloss.Width(bgLine)
510-
for bgVisualWidth < screenWidth {
511-
bgRunes = append(bgRunes, ' ')
512-
bgVisualWidth++
513-
}
514-
515-
var result strings.Builder
516-
517-
// Write background chars before modal
518-
prefixWidth := 0
519-
runeIdx := 0
520-
for runeIdx < len(bgRunes) && prefixWidth < offsetX {
521-
result.WriteRune(bgRunes[runeIdx])
522-
prefixWidth++
523-
runeIdx++
524-
}
525-
526-
// Pad if background was shorter than offsetX
527-
for prefixWidth < offsetX {
528-
result.WriteByte(' ')
529-
prefixWidth++
530-
}
531569

532-
// Write the modal line
533-
result.WriteString(mLine)
570+
// Build: bg prefix (ANSI-aware) + modal line + bg suffix (ANSI-aware)
571+
prefix := ansiTruncate(bgLine, offsetX)
572+
suffix := ansiSkip(bgLine, offsetX+mWidth)
534573

535-
bgLines[bgIdx] = result.String()
574+
bgLines[bgIdx] = prefix + mLine + suffix
536575
}
537576

538577
return strings.Join(bgLines[:screenHeight], "\n")

internal/tui/confetti.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ type Confetti struct {
3737
height int
3838
}
3939

40+
// SetSize updates the confetti bounds to match the current screen size.
41+
func (c *Confetti) SetSize(width, height int) {
42+
c.width = width
43+
c.height = height
44+
}
45+
4046
// NewConfetti creates a new confetti system with particles spread across the screen.
4147
func NewConfetti(width, height int) *Confetti {
4248
c := &Confetti{
@@ -62,20 +68,27 @@ func NewConfetti(width, height int) *Confetti {
6268
return c
6369
}
6470

65-
// Tick advances all particles by one frame.
71+
// Tick advances all particles by one frame, respawning expired ones at the top.
6672
func (c *Confetti) Tick() {
67-
alive := c.particles[:0]
6873
for i := range c.particles {
6974
p := &c.particles[i]
7075
p.x += p.vx
7176
p.y += p.vy
7277
p.life--
7378

74-
if p.life > 0 && p.y < float64(c.height) {
75-
alive = append(alive, *p)
79+
// Respawn particle at top when it expires or falls off screen
80+
if p.life <= 0 || p.y >= float64(c.height) {
81+
c.particles[i] = Particle{
82+
x: rand.Float64() * float64(c.width),
83+
y: -rand.Float64() * float64(c.height/3),
84+
vx: (rand.Float64() - 0.5) * 0.6,
85+
vy: 0.2 + rand.Float64()*0.4,
86+
char: confettiChars[rand.Intn(len(confettiChars))],
87+
color: confettiColors[rand.Intn(len(confettiColors))],
88+
life: 80 + rand.Intn(120),
89+
}
7690
}
7791
}
78-
c.particles = alive
7992
}
8093

8194
// Render draws all particles onto a character grid and returns it as a string.

0 commit comments

Comments
 (0)