Skip to content
Open
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 animations/beamtext.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type BeamTextConfig struct {
// NewBeamTextEffect creates a new beam text effect with given configuration
func NewBeamTextEffect(config BeamTextConfig) *BeamTextEffect {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
config.Text = normalizeMultilineText(config.Text)

// Set defaults if not provided
if len(config.BeamRowSymbols) == 0 {
Expand Down Expand Up @@ -830,7 +831,7 @@ func (b *BeamTextEffect) Reset() {

// SetText updates the displayed text and reinitializes the beam animation.
func (b *BeamTextEffect) SetText(text string) {
b.text = text
b.text = normalizeMultilineText(text)
b.chars = b.chars[:0]
b.rowGroups = b.rowGroups[:0]
b.columnGroups = b.columnGroups[:0]
Expand Down
5 changes: 3 additions & 2 deletions animations/blackhole.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ var unstableSymbols = []rune{'◦', '◎', '◉', '●'}
// NewBlackholeEffect creates a new Blackhole effect
func NewBlackholeEffect(config BlackholeConfig) *BlackholeEffect {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
config.Text = normalizeMultilineText(config.Text)

// Set defaults
if config.BlackholeColor == "" {
Expand Down Expand Up @@ -819,8 +820,8 @@ func (e *BlackholeEffect) Reset() {

// SetText updates the displayed text and reinitializes the blackhole animation.
func (e *BlackholeEffect) SetText(text string) {
e.text = text
e.particleMode = text == ""
e.text = normalizeMultilineText(text)
e.particleMode = e.text == ""
e.init()
}

Expand Down
9 changes: 9 additions & 0 deletions animations/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
// See GUIDE.md for detailed usage examples and integration patterns.
package animations

import "strings"

// Animation interface that all effects implement
type Animation interface {
// Update advances the animation by one frame
Expand Down Expand Up @@ -49,3 +51,10 @@ func IsTextUpdatable(anim Animation) bool {
_, ok := anim.(TextUpdatable)
return ok
}

// normalizeMultilineText normalizes platform line endings to LF.
func normalizeMultilineText(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return text
}
4 changes: 3 additions & 1 deletion animations/firetext.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type FireTextEffect struct {

// NewFireTextEffect creates a new fire-text effect with given dimensions, palette, and ASCII art
func NewFireTextEffect(width, height int, palette []string, text string) *FireTextEffect {
text = normalizeMultilineText(text)

f := &FireTextEffect{
width: width,
height: height,
Expand Down Expand Up @@ -283,7 +285,7 @@ func (f *FireTextEffect) Render() string {

// SetText updates the displayed text and rebuilds the fire around it.
func (f *FireTextEffect) SetText(text string) {
f.text = text
f.text = normalizeMultilineText(text)
f.parseText()
f.init()
}
4 changes: 3 additions & 1 deletion animations/matrixart.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type FrozenMatrixChar struct {

// NewMatrixArtEffect creates a new matrix-art effect
func NewMatrixArtEffect(width, height int, palette []string, text string) *MatrixArtEffect {
text = normalizeMultilineText(text)

m := &MatrixArtEffect{
width: width,
height: height,
Expand Down Expand Up @@ -313,7 +315,7 @@ func (m *MatrixArtEffect) Reset() {

// SetText updates the displayed text and restarts the crystallization.
func (m *MatrixArtEffect) SetText(text string) {
m.text = text
m.text = normalizeMultilineText(text)
m.artPositions = make(map[int]map[int]rune)
m.frozenChars = make(map[int]map[int]*FrozenMatrixChar)
m.parseArt()
Expand Down
3 changes: 2 additions & 1 deletion animations/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func calculatePrintTextDimensions(text string) (int, int) {

// NewPrintEffect creates a new print effect with given configuration
func NewPrintEffect(config PrintConfig) *PrintEffect {
config.Text = normalizeMultilineText(config.Text)
lines := strings.Split(config.Text, "\n")

// Don't remove empty lines - they might be part of ASCII art structure!
Expand Down Expand Up @@ -364,7 +365,7 @@ func (p *PrintEffect) Reset() {

// SetText updates the displayed text and restarts the print animation.
func (p *PrintEffect) SetText(text string) {
p.text = text
p.text = normalizeMultilineText(text)
p.Reset()
}

Expand Down
4 changes: 3 additions & 1 deletion animations/rainart.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type FrozenChar struct {

// NewRainArtEffect creates a new rain-art effect
func NewRainArtEffect(width, height int, palette []string, text string) *RainArtEffect {
text = normalizeMultilineText(text)

r := &RainArtEffect{
width: width,
height: height,
Expand Down Expand Up @@ -248,7 +250,7 @@ func (r *RainArtEffect) Reset() {

// SetText updates the displayed text and restarts the crystallization.
func (r *RainArtEffect) SetText(text string) {
r.text = text
r.text = normalizeMultilineText(text)
r.artPositions = make(map[int]map[int]rune)
r.frozenChars = make(map[int]map[int]*FrozenChar)
r.parseArt()
Expand Down
3 changes: 2 additions & 1 deletion animations/ringtext.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type Ring struct {
// NewRingTextEffect creates a new RingText effect
func NewRingTextEffect(config RingTextConfig) *RingTextEffect {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
config.Text = normalizeMultilineText(config.Text)

// Set defaults
if config.RingGap == 0 {
Expand Down Expand Up @@ -618,7 +619,7 @@ func (e *RingTextEffect) Reset() {

// SetText updates the displayed text and reinitializes the ring animation.
func (e *RingTextEffect) SetText(text string) {
e.text = text
e.text = normalizeMultilineText(text)
e.init()
}

Expand Down
6 changes: 4 additions & 2 deletions animations/skull.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ func NewSkullTextEffect(width, height int, palette []string, theme string, text

// newSkullAnimation is the internal constructor
func newSkullAnimation(width, height int, palette []string, theme string, withText bool, text string) *SkullAnimation {
text = normalizeMultilineText(text)

s := &SkullAnimation{
width: width,
height: height,
Expand Down Expand Up @@ -855,8 +857,8 @@ func (s *SkullAnimation) Reset() {

// SetText updates the displayed text and reinitializes the text layer.
func (s *SkullAnimation) SetText(text string) {
s.textContent = text
s.withText = text != ""
s.textContent = normalizeMultilineText(text)
s.withText = s.textContent != ""
s.textChars = nil
s.parseText()

Expand Down
100 changes: 92 additions & 8 deletions animations/text_updatable_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package animations

import (
"strings"
"testing"
)

func testPalette() []string {
func textUpdatableTestPalette() []string {
return []string{
"#000000", "#1a0000", "#330000", "#4d0000",
"#660000", "#800000", "#990000", "#cc0000",
Expand All @@ -14,7 +15,7 @@ func testPalette() []string {

func TestTextBasedEffectsSatisfyTextUpdatable(t *testing.T) {
w, h := 40, 20
p := testPalette()
p := textUpdatableTestPalette()
text := "TEST"

effects := map[string]interface{}{
Expand Down Expand Up @@ -51,7 +52,7 @@ type updatableRenderer interface {

func TestSetText_ChangesRenderedOutput(t *testing.T) {
w, h := 60, 20
p := testPalette()
p := textUpdatableTestPalette()
textA := "AAAA"
textB := "ZZZZ"

Expand Down Expand Up @@ -111,7 +112,7 @@ func TestSetText_ChangesRenderedOutput(t *testing.T) {

func TestSetText_DoesNotPanic(t *testing.T) {
w, h := 40, 20
p := testPalette()
p := textUpdatableTestPalette()

effects := map[string]interface{}{
"fire-text": NewFireTextEffect(w, h, p, "HELLO"),
Expand Down Expand Up @@ -161,7 +162,7 @@ func TestSetText_DoesNotPanic(t *testing.T) {

func TestSetText_StabilityAfterMultipleCalls(t *testing.T) {
w, h := 40, 20
p := testPalette()
p := textUpdatableTestPalette()

anim := NewFireTextEffect(w, h, p, "HELLO")

Expand All @@ -181,7 +182,7 @@ func TestSetText_StabilityAfterMultipleCalls(t *testing.T) {
}

func TestBurnSetText_ReplacesCharacters(t *testing.T) {
anim := NewBurnTextEffect(40, 20, testPalette(), "default", "AB")
anim := NewBurnTextEffect(40, 20, textUpdatableTestPalette(), "default", "AB")

if got := len(anim.chars); got != 2 {
t.Fatalf("expected 2 chars after init, got %d", got)
Expand All @@ -198,9 +199,92 @@ func TestBurnSetText_ReplacesCharacters(t *testing.T) {
}
}

func TestTextEffects_NormalizeCRLFInput(t *testing.T) {
w, h := 80, 24
p := textUpdatableTestPalette()
skullPalette := GetSkullPalette("default")
crlfText := "A\r\nB"

type effectFactory struct {
name string
frames int
create func(text string) updatableRenderer
}

factories := []effectFactory{
{"fire-text", 40, func(text string) updatableRenderer { return NewFireTextEffect(w, h, p, text) }},
{"matrix-art", 40, func(text string) updatableRenderer { return NewMatrixArtEffect(w, h, p, text) }},
{"rain-art", 40, func(text string) updatableRenderer { return NewRainArtEffect(w, h, p, text) }},
{"ring-text", 40, func(text string) updatableRenderer {
return NewRingTextEffect(RingTextConfig{Width: w, Height: h, Text: text})
}},
{"blackhole", 40, func(text string) updatableRenderer {
return NewBlackholeEffect(BlackholeConfig{Width: w, Height: h, Text: text})
}},
{"beam-text", 40, func(text string) updatableRenderer {
return NewBeamTextEffect(BeamTextConfig{Width: w, Height: h, Text: text})
}},
{"print", 80, func(text string) updatableRenderer {
return NewPrintEffect(PrintConfig{Width: w, Height: h, Text: text})
}},
{"skull-text", 40, func(text string) updatableRenderer {
return NewSkullTextEffect(w, h, skullPalette, "default", text)
}},
}

for _, ef := range factories {
t.Run(ef.name, func(t *testing.T) {
anim := ef.create(crlfText)
for i := 0; i < ef.frames; i++ {
anim.Update()
}

output := anim.Render()
if strings.ContainsRune(output, '\r') {
t.Fatalf("render output contains carriage return for CRLF input")
}

anim.SetText(crlfText)
for i := 0; i < ef.frames; i++ {
anim.Update()
}
output = anim.Render()
if strings.ContainsRune(output, '\r') {
t.Fatalf("render output contains carriage return after SetText with CRLF input")
}
})
}
}

func TestFireAndSkullText_NormalizeStoredText(t *testing.T) {
w, h := 80, 24
p := textUpdatableTestPalette()
skullPalette := GetSkullPalette("default")
crlfText := "A\r\nB"
lfText := "A\nB"

fire := NewFireTextEffect(w, h, p, crlfText)
if fire.text != lfText {
t.Fatalf("fire-text constructor did not normalize CRLF: got %q want %q", fire.text, lfText)
}
fire.SetText(crlfText)
if fire.text != lfText {
t.Fatalf("fire-text SetText did not normalize CRLF: got %q want %q", fire.text, lfText)
}

skull := NewSkullTextEffect(w, h, skullPalette, "default", crlfText)
if skull.textContent != lfText {
t.Fatalf("skull-text constructor did not normalize CRLF: got %q want %q", skull.textContent, lfText)
}
skull.SetText(crlfText)
if skull.textContent != lfText {
t.Fatalf("skull-text SetText did not normalize CRLF: got %q want %q", skull.textContent, lfText)
}
}

func TestIsTextUpdatable(t *testing.T) {
textEffect := NewFireTextEffect(40, 20, testPalette(), "TEST")
nonTextEffect := NewFireEffect(40, 20, testPalette())
textEffect := NewFireTextEffect(40, 20, textUpdatableTestPalette(), "TEST")
nonTextEffect := NewFireEffect(40, 20, textUpdatableTestPalette())

if _, ok := interface{}(textEffect).(TextUpdatable); !ok {
t.Error("FireTextEffect should be TextUpdatable")
Expand Down