Skip to content

Commit eea6a9d

Browse files
committed
feat(transition | tui): add fade transition animation
- add `fade.go` with fade transition effect - register fade transition in transition factory function - implement spring-based animation with harmonica library
1 parent 6742abb commit eea6a9d

File tree

2 files changed

+207
-0
lines changed

2 files changed

+207
-0
lines changed

internal/tui/transitions/fade.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package transitions
2+
3+
import (
4+
"math"
5+
"math/rand"
6+
"strings"
7+
"time"
8+
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/harmonica"
11+
charmansi "github.com/charmbracelet/x/ansi"
12+
"github.com/muesli/reflow/truncate"
13+
14+
"github.com/museslabs/kyma/internal/skip"
15+
)
16+
17+
type tileGrid struct {
18+
tileSize int
19+
gridWidth int
20+
gridHeight int
21+
tileOrder []int
22+
}
23+
24+
func newTileGrid(width, height, tileSize int) tileGrid {
25+
gridWidth := (width + tileSize - 1) / tileSize
26+
gridHeight := (height + tileSize - 1) / tileSize
27+
28+
totalTiles := gridWidth * gridHeight
29+
tileOrder := make([]int, totalTiles)
30+
for i := range totalTiles {
31+
tileOrder[i] = i
32+
}
33+
34+
r := rand.New(rand.NewSource(42))
35+
r.Shuffle(len(tileOrder), func(i, j int) {
36+
tileOrder[i], tileOrder[j] = tileOrder[j], tileOrder[i]
37+
})
38+
39+
return tileGrid{
40+
tileSize: tileSize,
41+
gridWidth: gridWidth,
42+
gridHeight: gridHeight,
43+
tileOrder: tileOrder,
44+
}
45+
}
46+
47+
func (g tileGrid) revealedTiles(progress float64) map[int]bool {
48+
totalTiles := len(g.tileOrder)
49+
revealedCount := int(math.Round(progress * float64(totalTiles)))
50+
51+
revealedSet := make(map[int]bool)
52+
for i := range revealedCount {
53+
if i < len(g.tileOrder) {
54+
revealedSet[g.tileOrder[i]] = true
55+
}
56+
}
57+
return revealedSet
58+
}
59+
60+
func (g tileGrid) tileIndex(x, y int) int {
61+
tileX := x / g.tileSize
62+
tileY := y / g.tileSize
63+
return tileY*g.gridWidth + tileX
64+
}
65+
66+
type fade struct {
67+
width int
68+
height int
69+
fps int
70+
spring harmonica.Spring
71+
progress float64
72+
vel float64
73+
animating bool
74+
direction direction
75+
grid tileGrid
76+
}
77+
78+
func newFade(fps int) fade {
79+
const frequency = 15
80+
const damping = 0.65
81+
82+
return fade{
83+
fps: fps,
84+
spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping),
85+
}
86+
}
87+
88+
func (t fade) Start(width, height int, direction direction) Transition {
89+
t.width = width
90+
t.height = height
91+
t.animating = true
92+
t.progress = 0
93+
t.vel = 0
94+
t.direction = direction
95+
t.grid = newTileGrid(width, height, 2)
96+
97+
return t
98+
}
99+
100+
func (t fade) Animating() bool {
101+
return t.animating
102+
}
103+
104+
func (t fade) Update() (Transition, tea.Cmd) {
105+
targetProgress := 1.0
106+
107+
t.progress, t.vel = t.spring.Update(t.progress, t.vel, targetProgress)
108+
109+
if t.progress >= 0.99 {
110+
t.animating = false
111+
t.progress = 1.0
112+
return t, nil
113+
}
114+
115+
return t, Animate(time.Duration(t.fps))
116+
}
117+
118+
func (t fade) View(prev, next string) string {
119+
var s strings.Builder
120+
121+
prevLines := strings.Split(prev, "\n")
122+
nextLines := strings.Split(next, "\n")
123+
124+
// Ensure slides are equal height
125+
maxLines := max(len(nextLines), len(prevLines))
126+
127+
// Get revealed tiles from grid
128+
revealedSet := t.grid.revealedTiles(t.progress)
129+
allRevealed := len(revealedSet) >= len(t.grid.tileOrder)
130+
131+
for lineIdx := range maxLines {
132+
var prevLine, nextLine string
133+
134+
if lineIdx < len(prevLines) {
135+
prevLine = prevLines[lineIdx]
136+
}
137+
if lineIdx < len(nextLines) {
138+
nextLine = nextLines[lineIdx]
139+
}
140+
141+
var line string
142+
if allRevealed {
143+
line = truncate.String(nextLine, uint(t.width))
144+
} else {
145+
line = t.buildFadeLine(prevLine, nextLine, revealedSet, lineIdx)
146+
}
147+
148+
s.WriteString(line)
149+
if lineIdx < maxLines-1 {
150+
s.WriteString("\n")
151+
}
152+
}
153+
154+
return s.String()
155+
}
156+
157+
func (t fade) buildFadeLine(prevLine, nextLine string, revealedSet map[int]bool, lineIdx int) string {
158+
var result strings.Builder
159+
160+
for tileX := range t.grid.gridWidth {
161+
startPos := tileX * t.grid.tileSize
162+
endPos := min(startPos+t.grid.tileSize, t.width)
163+
tileWidth := endPos - startPos
164+
165+
tileIndex := t.grid.tileIndex(startPos, lineIdx)
166+
167+
// Choose source line based on tile state
168+
sourceLine := prevLine
169+
if revealedSet[tileIndex] {
170+
sourceLine = nextLine
171+
}
172+
173+
// Extract tile segment
174+
segment := t.extractTileSegment(sourceLine, startPos, tileWidth)
175+
result.WriteString(segment)
176+
}
177+
178+
finalLine := result.String()
179+
if charmansi.StringWidth(finalLine) > t.width {
180+
finalLine = truncate.String(finalLine, uint(t.width))
181+
}
182+
183+
return finalLine
184+
}
185+
186+
func (t fade) extractTileSegment(line string, startPos, tileWidth int) string {
187+
if startPos == 0 {
188+
return truncate.String(line, uint(tileWidth))
189+
}
190+
191+
skipped := skip.String(line, uint(startPos))
192+
return truncate.String(skipped, uint(tileWidth))
193+
}
194+
195+
func (t fade) Name() string {
196+
return "fade"
197+
}
198+
199+
func (t fade) Opposite() Transition {
200+
return newFade(t.fps)
201+
}
202+
203+
func (t fade) Direction() direction {
204+
return t.direction
205+
}

internal/tui/transitions/transition.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ func Get(name string, fps int) Transition {
4949
return newCollapse(fps)
5050
case "expand":
5151
return newExpand(fps)
52+
case "fade":
53+
return newFade(fps)
5254
default:
5355
return newNoTransition(fps)
5456
}

0 commit comments

Comments
 (0)