Skip to content

Commit 9bddb3e

Browse files
committed
Add 80s retro syntwave visualizer
1 parent c5e381c commit 9bddb3e

3 files changed

Lines changed: 178 additions & 2 deletions

File tree

ui/keys.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
282282
case "V":
283283
m.fullVis = !m.fullVis
284284
if m.fullVis {
285-
m.vis.Rows = max(defaultVisRows, (m.height-10)*3/5)
285+
m.vis.Rows = max(defaultVisRows, (m.height-10)*4/5)
286286
} else {
287287
m.vis.Rows = defaultVisRows
288288
}

ui/model.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
642642
m.width = msg.Width
643643
m.height = msg.Height
644644
if m.fullVis {
645-
m.vis.Rows = max(defaultVisRows, (m.height-10)*3/5)
645+
m.vis.Rows = max(defaultVisRows, (m.height-10)*4/5)
646646
}
647647

648648
case tickMsg:

ui/visualizer.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
VisWave // braille waveform oscilloscope
2626
VisScatter // braille particle sparkle
2727
VisFlame // braille rising flame tendrils
28+
VisRetro // 80s synthwave perspective grid with wave
2829
VisNone // hidden — no visualizer
2930
visCount // sentinel for cycling
3031
)
@@ -88,6 +89,8 @@ func (v *Visualizer) ModeName() string {
8889
return "Scatter"
8990
case VisFlame:
9091
return "Flame"
92+
case VisRetro:
93+
return "Retro"
9194
case VisNone:
9295
return "None"
9396
default:
@@ -189,6 +192,8 @@ func (v *Visualizer) Render(bands [numBands]float64) string {
189192
return v.renderScatter(bands)
190193
case VisFlame:
191194
return v.renderFlame(bands)
195+
case VisRetro:
196+
return v.renderRetro(bands)
192197
case VisNone:
193198
return ""
194199
default:
@@ -523,6 +528,177 @@ func (v *Visualizer) renderFlame(bands [numBands]float64) string {
523528
return strings.Join(lines, "\n")
524529
}
525530

531+
// renderRetro draws a retro 80s synthwave scene: a striped setting sun above
532+
// the horizon, a smooth audio-reactive wave, and a perspective grid floor that
533+
// scrolls toward the viewer. Uses Braille characters for sub-cell resolution.
534+
func (v *Visualizer) renderRetro(bands [numBands]float64) string {
535+
height := v.Rows
536+
const charCols = 69
537+
dotRows := height * 4
538+
dotCols := charCols * 2
539+
540+
// Horizon at 40% from top — gives room for wave and sun above.
541+
horizonDot := dotRows * 2 / 5
542+
if horizonDot < 2 {
543+
horizonDot = 2
544+
}
545+
floorRows := dotRows - horizonDot
546+
centerX := float64(dotCols-1) / 2.0
547+
548+
// Dot types: 0=empty, 1=grid, 2=wave, 3=sun.
549+
grid := make([][]byte, dotRows)
550+
for i := range grid {
551+
grid[i] = make([]byte, dotCols)
552+
}
553+
554+
// ── SUN ── striped semicircle above horizon.
555+
sunR := float64(horizonDot) * 0.85
556+
for dy := range horizonDot {
557+
rowDist := float64(horizonDot - dy) // dots above horizon
558+
if rowDist > sunR {
559+
continue
560+
}
561+
halfW := math.Sqrt(sunR*sunR - rowDist*rowDist)
562+
563+
// Bottom half of sun has horizontal stripe gaps.
564+
if rowDist < sunR*0.5 {
565+
sw := max(1, int(sunR*0.15))
566+
if (int(rowDist)/sw)%2 == 1 {
567+
continue
568+
}
569+
}
570+
571+
left := max(0, int(centerX-halfW))
572+
right := min(dotCols-1, int(centerX+halfW))
573+
for dx := left; dx <= right; dx++ {
574+
grid[dy][dx] = 3
575+
}
576+
}
577+
578+
// ── HORIZON LINE ──
579+
for dx := range dotCols {
580+
grid[horizonDot][dx] = 1
581+
}
582+
583+
// ── PERSPECTIVE GRID FLOOR ──
584+
585+
// Vertical lines converging to vanishing point at (centerX, horizonDot).
586+
const numVLines = 18
587+
for i := range numVLines + 1 {
588+
bottomX := float64(i) * float64(dotCols-1) / float64(numVLines)
589+
for dy := horizonDot + 1; dy < dotRows; dy++ {
590+
t := float64(dy-horizonDot) / float64(max(1, floorRows-1))
591+
screenX := centerX + (bottomX-centerX)*t
592+
ix := int(math.Round(screenX))
593+
if ix >= 0 && ix < dotCols {
594+
grid[dy][ix] = 1
595+
}
596+
}
597+
}
598+
599+
// Horizontal lines scrolling toward the viewer.
600+
scroll := math.Mod(float64(v.frame)*0.08, 1.0)
601+
const numHLines = 10
602+
for i := range numHLines {
603+
z := (float64(i) + scroll) / float64(numHLines)
604+
if z > 1.0 {
605+
z -= 1.0
606+
}
607+
// Quadratic perspective: dense near horizon, spread near viewer.
608+
dy := horizonDot + 1 + int(z*z*float64(max(1, floorRows-2)))
609+
if dy > horizonDot && dy < dotRows {
610+
for dx := range dotCols {
611+
grid[dy][dx] = 1
612+
}
613+
}
614+
}
615+
616+
// ── AUDIO WAVE AT HORIZON ──
617+
waveY := make([]int, dotCols)
618+
maxWave := float64(horizonDot) * 0.85
619+
for dx := range dotCols {
620+
bandF := float64(dx) / float64(max(1, dotCols-1)) * float64(numBands-1)
621+
bi := int(bandF)
622+
frac := bandF - float64(bi)
623+
624+
// Cosine interpolation for a smooth curve between bands.
625+
t := (1 - math.Cos(frac*math.Pi)) / 2
626+
627+
var level float64
628+
if bi >= numBands-1 {
629+
level = bands[numBands-1]
630+
} else {
631+
level = bands[bi]*(1-t) + bands[bi+1]*t
632+
}
633+
634+
// Small floor so the wave never fully vanishes.
635+
level = max(0.03, level)
636+
637+
wy := horizonDot - int(level*maxWave)
638+
waveY[dx] = max(0, min(dotRows-1, wy))
639+
}
640+
641+
// Draw wave with continuous line connections.
642+
for dx := range dotCols {
643+
y := waveY[dx]
644+
grid[y][dx] = 2
645+
if dx > 0 {
646+
lo, hi := min(y, waveY[dx-1]), max(y, waveY[dx-1])
647+
for fy := lo; fy <= hi; fy++ {
648+
grid[fy][dx] = 2
649+
}
650+
}
651+
}
652+
653+
// ── RENDER BRAILLE ──
654+
lines := make([]string, height)
655+
for row := range height {
656+
var sb strings.Builder
657+
base := row * 4
658+
659+
for ch := range charCols {
660+
var braille rune = '\u2800'
661+
colBase := ch * 2
662+
hasWave, hasSun := false, false
663+
664+
for dr := range 4 {
665+
for dc := range 2 {
666+
dy := base + dr
667+
dx := colBase + dc
668+
if dy >= dotRows || dx >= dotCols {
669+
continue
670+
}
671+
switch grid[dy][dx] {
672+
case 1:
673+
braille |= brailleBit[dr][dc]
674+
case 2:
675+
braille |= brailleBit[dr][dc]
676+
hasWave = true
677+
case 3:
678+
braille |= brailleBit[dr][dc]
679+
hasSun = true
680+
}
681+
}
682+
}
683+
684+
// Priority: wave (red) > sun (yellow) > grid (green).
685+
var style lipgloss.Style
686+
switch {
687+
case hasWave:
688+
style = specHighStyle
689+
case hasSun:
690+
style = specMidStyle
691+
default:
692+
style = specLowStyle
693+
}
694+
sb.WriteString(style.Render(string(braille)))
695+
}
696+
lines[row] = sb.String()
697+
}
698+
699+
return strings.Join(lines, "\n")
700+
}
701+
526702
// specStyle returns the spectrum color style for a given row height (0-1).
527703
func specStyle(rowBottom float64) lipgloss.Style {
528704
switch {

0 commit comments

Comments
 (0)