diff --git a/animations/beamtext.go b/animations/beamtext.go index 1f85317..558178d 100644 --- a/animations/beamtext.go +++ b/animations/beamtext.go @@ -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 { @@ -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] diff --git a/animations/blackhole.go b/animations/blackhole.go index 2007abc..2d4c6c7 100644 --- a/animations/blackhole.go +++ b/animations/blackhole.go @@ -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 == "" { @@ -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() } diff --git a/animations/common.go b/animations/common.go index f3bb4b1..d6ec739 100644 --- a/animations/common.go +++ b/animations/common.go @@ -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 @@ -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 +} diff --git a/animations/firetext.go b/animations/firetext.go index 56255b6..eec4bc1 100644 --- a/animations/firetext.go +++ b/animations/firetext.go @@ -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, @@ -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() } diff --git a/animations/matrixart.go b/animations/matrixart.go index 78556ed..4b752b2 100644 --- a/animations/matrixart.go +++ b/animations/matrixart.go @@ -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, @@ -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() diff --git a/animations/print.go b/animations/print.go index 64d9b84..e7d5e2d 100644 --- a/animations/print.go +++ b/animations/print.go @@ -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! @@ -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() } diff --git a/animations/rainart.go b/animations/rainart.go index a379b59..0b0d2e8 100644 --- a/animations/rainart.go +++ b/animations/rainart.go @@ -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, @@ -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() diff --git a/animations/ringtext.go b/animations/ringtext.go index 99bc074..0e7afa2 100644 --- a/animations/ringtext.go +++ b/animations/ringtext.go @@ -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 { @@ -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() } diff --git a/animations/skull.go b/animations/skull.go index d12bcaa..ee46b83 100644 --- a/animations/skull.go +++ b/animations/skull.go @@ -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, @@ -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() diff --git a/animations/text_updatable_test.go b/animations/text_updatable_test.go index 1d78069..9199198 100644 --- a/animations/text_updatable_test.go +++ b/animations/text_updatable_test.go @@ -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", @@ -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{}{ @@ -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" @@ -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"), @@ -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") @@ -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) @@ -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")