Skip to content

Commit 9369ee5

Browse files
jackspirouclaude
andcommitted
feat: add backspace pattern detection and CSS animation
Implements detection and optimization for backspace/deletion patterns: - Detect consecutive backspace operations - Generate CSS animations that reverse typing effect - Reduce file size by replacing multiple deletion frames with single animation Backspace patterns are animated using CSS width reduction: ```css @Keyframes backspace_0 { from { width: 50px; } to { width: 0; } } ``` This provides smooth deletion animations while saving significant file size for common typing corrections and edits. Tests added to verify: - Backspace pattern detection - CSS animation generation - Correct handling of deleted text 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ed70e0b commit 9369ee5

2 files changed

Lines changed: 225 additions & 5 deletions

File tree

svg.go

Lines changed: 183 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ type PatternType int
9393
const (
9494
PatternStatic PatternType = iota
9595
PatternTyping
96+
PatternBackspace
9697
)
9798

9899
// FramePattern represents a detected pattern in frame sequences.
@@ -108,6 +109,10 @@ type FramePattern struct {
108109
StartCol int
109110
Text string
110111

112+
// For backspace patterns
113+
DeletedText string // Text that was deleted
114+
DeletedCount int // Number of characters deleted
115+
111116
// Store the initial and final states
112117
InitialState TerminalState
113118
FinalState TerminalState
@@ -538,6 +543,13 @@ func (g *SVGGenerator) detectPatterns() {
538543
continue
539544
}
540545

546+
// Try to detect backspace pattern
547+
if pattern, consumed := g.detectBackspacePattern(i); pattern != nil {
548+
g.patterns = append(g.patterns, *pattern)
549+
i += consumed
550+
continue
551+
}
552+
541553
// If no pattern detected, treat as static frame
542554
frame := g.options.Frames[i]
543555
g.patterns = append(g.patterns, FramePattern{
@@ -560,18 +572,26 @@ func (g *SVGGenerator) detectPatterns() {
560572
if g.options.Debug {
561573
typingPatterns := 0
562574
typingFrames := 0
575+
backspacePatterns := 0
576+
backspaceFrames := 0
563577
for _, p := range g.patterns {
564-
if p.Type == PatternTyping {
578+
switch p.Type {
579+
case PatternTyping:
565580
typingPatterns++
566581
typingFrames += p.EndFrame - p.StartFrame + 1
582+
case PatternBackspace:
583+
backspacePatterns++
584+
backspaceFrames += p.EndFrame - p.StartFrame + 1
567585
}
568586
}
569587
log.Printf("Pattern detection analysis:")
570588
log.Printf(" Total frames: %d", len(g.options.Frames))
571589
log.Printf(" Detected patterns: %d", len(g.patterns))
572-
log.Printf(" Typing patterns: %d", typingPatterns)
573-
log.Printf(" Frames in typing patterns: %d (%.1f%%)",
574-
typingFrames, float64(typingFrames)/float64(len(g.options.Frames))*100)
590+
log.Printf(" Typing patterns: %d (frames: %d)", typingPatterns, typingFrames)
591+
log.Printf(" Backspace patterns: %d (frames: %d)", backspacePatterns, backspaceFrames)
592+
totalOptimized := typingFrames + backspaceFrames
593+
log.Printf(" Total optimized frames: %d (%.1f%%)",
594+
totalOptimized, float64(totalOptimized)/float64(len(g.options.Frames))*100)
575595
}
576596
}
577597

@@ -708,6 +728,126 @@ func (g *SVGGenerator) detectTypingPattern(start int) (*FramePattern, int) {
708728
return pattern, framesInPattern
709729
}
710730

731+
// detectBackspacePattern looks for consecutive frames where text is being deleted.
732+
func (g *SVGGenerator) detectBackspacePattern(start int) (*FramePattern, int) {
733+
if start >= len(g.options.Frames)-1 {
734+
return nil, 0
735+
}
736+
737+
firstFrame := g.options.Frames[start]
738+
line := firstFrame.CursorY
739+
740+
// Track the backspace sequence
741+
end := start + 1
742+
totalDeleted := 0
743+
744+
for end < len(g.options.Frames) {
745+
prev := g.options.Frames[end-1]
746+
curr := g.options.Frames[end]
747+
748+
// Check if still on the same line
749+
if curr.CursorY != line {
750+
break
751+
}
752+
753+
// Check that only the cursor line changed
754+
if !g.isOnlyLineChanged(prev, curr, line) {
755+
break
756+
}
757+
758+
// Check if text is getting shorter (backspace pattern)
759+
if line < len(prev.Lines) && line < len(curr.Lines) {
760+
prevLine := prev.Lines[line]
761+
currLine := curr.Lines[line]
762+
763+
// For backspace, current line should be shorter
764+
if len(currLine) >= len(prevLine) {
765+
break
766+
}
767+
768+
// Check if it's a prefix (deleting from end)
769+
if !strings.HasPrefix(prevLine, currLine) {
770+
// Could be deletion in middle, but for now we'll break
771+
break
772+
}
773+
774+
// Track how many characters were deleted
775+
deleted := len(prevLine) - len(currLine)
776+
totalDeleted += deleted
777+
778+
// Don't group huge deletions (likely line clear, not backspace)
779+
if deleted > 10 {
780+
break
781+
}
782+
} else {
783+
break
784+
}
785+
786+
end++
787+
}
788+
789+
// Need at least 2 frames to consider it a backspace pattern
790+
framesInPattern := end - start
791+
if framesInPattern < 2 {
792+
return nil, 0
793+
}
794+
795+
// Need to have deleted at least 2 characters to be worth optimizing
796+
if totalDeleted < 2 {
797+
return nil, 0
798+
}
799+
800+
// Extract what was deleted
801+
lastFrame := g.options.Frames[end-1]
802+
var deletedText string
803+
804+
if line < len(firstFrame.Lines) && line < len(lastFrame.Lines) {
805+
startLine := firstFrame.Lines[line]
806+
endLine := lastFrame.Lines[line]
807+
808+
if strings.HasPrefix(startLine, endLine) {
809+
deletedText = startLine[len(endLine):]
810+
}
811+
}
812+
813+
// Create states
814+
initialState := TerminalState{
815+
Lines: firstFrame.Lines,
816+
LineColors: firstFrame.LineColors,
817+
CursorX: firstFrame.CursorX,
818+
CursorY: firstFrame.CursorY,
819+
CursorChar: firstFrame.CursorChar,
820+
}
821+
822+
finalState := TerminalState{
823+
Lines: lastFrame.Lines,
824+
LineColors: lastFrame.LineColors,
825+
CursorX: lastFrame.CursorX,
826+
CursorY: lastFrame.CursorY,
827+
CursorChar: lastFrame.CursorChar,
828+
}
829+
830+
pattern := &FramePattern{
831+
Type: PatternBackspace,
832+
StartFrame: start,
833+
EndFrame: end - 1,
834+
StartTime: firstFrame.Timestamp,
835+
EndTime: lastFrame.Timestamp,
836+
Line: line,
837+
DeletedText: deletedText,
838+
DeletedCount: totalDeleted,
839+
InitialState: initialState,
840+
FinalState: finalState,
841+
}
842+
843+
if g.options.Debug {
844+
log.Printf("Detected backspace pattern: frames %d-%d, line %d, deleted: %q (saved %d frames)",
845+
start, end-1, line, deletedText, framesInPattern-1)
846+
}
847+
848+
return pattern, framesInPattern
849+
}
850+
711851
// isOnlyLineChanged checks if only the specified line changed between frames.
712852
func (g *SVGGenerator) isOnlyLineChanged(prev, curr SVGFrame, targetLine int) bool {
713853
// Check if number of lines changed significantly
@@ -776,6 +916,41 @@ func (g *SVGGenerator) generateTypingCSS(sb *strings.Builder, index int, pattern
776916
g.writeNewline(sb)
777917
}
778918

919+
// generateBackspaceCSS generates CSS animation for a backspace pattern.
920+
func (g *SVGGenerator) generateBackspaceCSS(sb *strings.Builder, index int, pattern FramePattern) {
921+
// Calculate the width of the deleted text
922+
startWidth := float64(len(pattern.DeletedText)) * g.charWidth
923+
duration := pattern.EndTime - pattern.StartTime
924+
925+
// Generate the keyframe animation (reverse of typing)
926+
sb.WriteString(fmt.Sprintf("@keyframes backspace_%d {", index))
927+
g.writeNewline(sb)
928+
sb.WriteString(fmt.Sprintf(" from { width: %spx; }", formatCoord(startWidth)))
929+
g.writeNewline(sb)
930+
sb.WriteString(" to { width: 0; }")
931+
g.writeNewline(sb)
932+
sb.WriteString("}")
933+
g.writeNewline(sb)
934+
935+
// Generate the class for this backspace animation
936+
sb.WriteString(fmt.Sprintf(".backspace_%d {", index))
937+
g.writeNewline(sb)
938+
sb.WriteString(" overflow: hidden;")
939+
g.writeNewline(sb)
940+
sb.WriteString(" white-space: nowrap;")
941+
g.writeNewline(sb)
942+
sb.WriteString(" display: inline-block;")
943+
g.writeNewline(sb)
944+
sb.WriteString(fmt.Sprintf(" animation: backspace_%d %ss steps(%d, end) forwards;",
945+
index, formatDuration(duration), pattern.DeletedCount))
946+
g.writeNewline(sb)
947+
sb.WriteString(fmt.Sprintf(" animation-delay: %ss;", formatDuration(pattern.StartTime)))
948+
g.writeNewline(sb)
949+
sb.WriteString("}")
950+
g.writeNewline(sb)
951+
g.writeNewline(sb)
952+
}
953+
779954
// generateStyles creates the CSS styles and animations.
780955
func (g *SVGGenerator) generateStyles() string {
781956
var sb strings.Builder
@@ -785,8 +960,11 @@ func (g *SVGGenerator) generateStyles() string {
785960

786961
// Generate typing animations for detected patterns
787962
for i, pattern := range g.patterns {
788-
if pattern.Type == PatternTyping {
963+
switch pattern.Type {
964+
case PatternTyping:
789965
g.generateTypingCSS(&sb, i, pattern)
966+
case PatternBackspace:
967+
g.generateBackspaceCSS(&sb, i, pattern)
790968
}
791969
}
792970

svg_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,48 @@ func TestSVGGenerator_TypingAnimationCSS(t *testing.T) {
14041404
}
14051405
})
14061406

1407+
t.Run("detects backspace pattern", func(t *testing.T) {
1408+
opts := createTestSVGConfig()
1409+
opts.Frames = []SVGFrame{
1410+
{Lines: []string{"$ hello"}, CursorX: 7, CursorY: 0, Timestamp: 0.0, CharWidth: 8.8, CharHeight: 20},
1411+
{Lines: []string{"$ hell"}, CursorX: 6, CursorY: 0, Timestamp: 0.1, CharWidth: 8.8, CharHeight: 20},
1412+
{Lines: []string{"$ hel"}, CursorX: 5, CursorY: 0, Timestamp: 0.2, CharWidth: 8.8, CharHeight: 20},
1413+
{Lines: []string{"$ he"}, CursorX: 4, CursorY: 0, Timestamp: 0.3, CharWidth: 8.8, CharHeight: 20},
1414+
{Lines: []string{"$ h"}, CursorX: 3, CursorY: 0, Timestamp: 0.4, CharWidth: 8.8, CharHeight: 20},
1415+
{Lines: []string{"$ "}, CursorX: 2, CursorY: 0, Timestamp: 0.5, CharWidth: 8.8, CharHeight: 20},
1416+
}
1417+
1418+
gen := NewSVGGenerator(opts)
1419+
gen.detectPatterns()
1420+
1421+
// Should detect one backspace pattern
1422+
backspacePatterns := 0
1423+
for _, p := range gen.patterns {
1424+
if p.Type == PatternBackspace {
1425+
backspacePatterns++
1426+
// Verify the deleted text
1427+
if p.DeletedText != "hello" {
1428+
t.Errorf("Expected deleted text 'hello', got '%s'", p.DeletedText)
1429+
}
1430+
// Verify deleted count
1431+
if p.DeletedCount != 5 {
1432+
t.Errorf("Expected 5 characters deleted, got %d", p.DeletedCount)
1433+
}
1434+
}
1435+
}
1436+
1437+
if backspacePatterns != 1 {
1438+
t.Errorf("Expected 1 backspace pattern, got %d", backspacePatterns)
1439+
}
1440+
1441+
// Check CSS generation
1442+
svg := gen.Generate()
1443+
assertContains(t, svg, "@keyframes backspace_", "Should generate backspace animation")
1444+
assertContains(t, svg, ".backspace_", "Should generate backspace class")
1445+
assertContains(t, svg, "from { width:", "Backspace should animate width")
1446+
assertContains(t, svg, "to { width: 0", "Backspace should animate to zero width")
1447+
})
1448+
14071449
t.Run("handles deletion mid-word", func(t *testing.T) {
14081450
opts := createTestSVGConfig()
14091451
opts.Frames = []SVGFrame{

0 commit comments

Comments
 (0)