diff --git a/README.md b/README.md index 6f5aa21..0a72a0c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ A terminal-based presentation tool that creates beautiful presentations from mar - Swipe left/right - Slide up/down - Flip effects + - Collapse and expand + - Fade in/out - **Hot reload**: Live reloading of presentation files during editing by default - **Customizable styling**: Configure borders, colors, and layouts via YAML front matter - **Theme support**: Choose from built-in Glamour themes or load custom JSON theme files @@ -130,6 +132,9 @@ This slide uses a custom JSON theme file - `slideUp` - Slide slides up from bottom - `slideDown` - Slide slides down from top - `flip` - Flip transition effect +- `collapse` - Collapse transition effect +- `expand` - Expand transition effect +- `fade` - Fade transition effect ### Style Configuration @@ -250,5 +255,5 @@ All contributions are welcome! If you're planning a significant change or you're - ~~Allow choosing from any glamour themes~~ ✅ **Done!** - ~~Support for custom JSON theme files~~ ✅ **Done!** - Create grid-based slide layouts with transitions for each pane -- Add more transition effects +- ~~Add more transition effects~~ ✅ **Done!** - Support image rendering in terminals (e.g., via the Kitty protocol) diff --git a/internal/tui/transitions/collapse.go b/internal/tui/transitions/collapse.go new file mode 100644 index 0000000..f0bd71d --- /dev/null +++ b/internal/tui/transitions/collapse.go @@ -0,0 +1,131 @@ +package transitions + +import ( + "math" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + charmansi "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/truncate" + + "github.com/museslabs/kyma/internal/skip" +) + +type collapse struct { + width int + fps int + spring harmonica.Spring + progress float64 + vel float64 + animating bool + direction direction +} + +func newCollapse(fps int) collapse { + const frequency = 7.0 + const damping = 0.6 + + return collapse{ + fps: fps, + spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), + } +} + +func (t collapse) Start(width, _ int, direction direction) Transition { + t.width = width + t.animating = true + t.progress = 0 + t.vel = 0 + t.direction = direction + return t +} + +func (t collapse) Animating() bool { + return t.animating +} + +func (t collapse) Update() (Transition, tea.Cmd) { + targetProgress := 1.0 + + t.progress, t.vel = t.spring.Update(t.progress, t.vel, targetProgress) + + if t.progress >= 0.99 { + t.animating = false + t.progress = 1.0 + return t, nil + } + + return t, Animate(time.Duration(t.fps)) +} + +func (t collapse) View(prev, next string) string { + var s strings.Builder + + // Calculate how much should collapse from edges toward center + collapseWidth := int(math.Round((1.0 - t.progress) * float64(t.width) / 2)) + centerStart := t.width/2 - collapseWidth + centerEnd := t.width/2 + collapseWidth + + prevLines := strings.Split(prev, "\n") + nextLines := strings.Split(next, "\n") + + // Ensure slides are equal height + maxLines := max(len(nextLines), len(prevLines)) + + for i := range maxLines { + var prevLine, nextLine string + + if i < len(prevLines) { + prevLine = prevLines[i] + } + if i < len(nextLines) { + nextLine = nextLines[i] + } + + var line string + if collapseWidth <= 0 { + // Animation complete, show next content + line = truncate.String(nextLine, uint(t.width)) + } else { + // Build the line with collapse effect from edges toward center + // Center portion: show prev content that's collapsing + var center string + if centerEnd > centerStart { + truncatedPrev := truncate.String(prevLine, uint(centerEnd)) + center = skip.String(truncatedPrev, uint(centerStart)) + } + + // Left and right parts: show next content appearing from edges + leftNext := truncate.String(nextLine, uint(centerStart)) + + rightNext := "" + if centerEnd < t.width { + rightNext = charmansi.TruncateLeft(nextLine, centerEnd, "") + } + + line = leftNext + center + rightNext + line = truncate.String(line, uint(t.width)) + } + + s.WriteString(line) + if i < maxLines-1 { + s.WriteString("\n") + } + } + + return s.String() +} + +func (t collapse) Name() string { + return "collapse" +} + +func (t collapse) Opposite() Transition { + return newExpand(t.fps) +} + +func (t collapse) Direction() direction { + return t.direction +} diff --git a/internal/tui/transitions/expand.go b/internal/tui/transitions/expand.go new file mode 100644 index 0000000..c681aeb --- /dev/null +++ b/internal/tui/transitions/expand.go @@ -0,0 +1,131 @@ +package transitions + +import ( + "math" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + charmansi "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/truncate" + + "github.com/museslabs/kyma/internal/skip" +) + +type expand struct { + width int + fps int + spring harmonica.Spring + progress float64 + vel float64 + animating bool + direction direction +} + +func newExpand(fps int) expand { + const frequency = 7.0 + const damping = 0.6 + + return expand{ + fps: fps, + spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), + } +} + +func (t expand) Start(width, _ int, direction direction) Transition { + t.width = width + t.animating = true + t.progress = 0 + t.vel = 0 + t.direction = direction + return t +} + +func (t expand) Animating() bool { + return t.animating +} + +func (t expand) Update() (Transition, tea.Cmd) { + targetProgress := 1.0 + + t.progress, t.vel = t.spring.Update(t.progress, t.vel, targetProgress) + + if t.progress >= 0.99 { + t.animating = false + t.progress = 1.0 + return t, nil + } + + return t, Animate(time.Duration(t.fps)) +} + +func (t expand) View(prev, next string) string { + var s strings.Builder + + // Calculate how much should expand from center outward + expandWidth := int(math.Round(t.progress * float64(t.width) / 2)) + centerStart := t.width/2 - expandWidth + centerEnd := t.width/2 + expandWidth + + prevLines := strings.Split(prev, "\n") + nextLines := strings.Split(next, "\n") + + // Ensure slides are equal height + maxLines := max(len(nextLines), len(prevLines)) + + for i := range maxLines { + var prevLine, nextLine string + + if i < len(prevLines) { + prevLine = prevLines[i] + } + if i < len(nextLines) { + nextLine = nextLines[i] + } + + var line string + if expandWidth >= t.width/2 { + // Animation complete, show next content + line = truncate.String(nextLine, uint(t.width)) + } else { + // Build the line with expand effect from center outward + // Left and right parts: show prev content + leftPrev := truncate.String(prevLine, uint(centerStart)) + + rightPrev := "" + if centerEnd < t.width { + rightPrev = charmansi.TruncateLeft(prevLine, centerEnd, "") + } + + // Center portion: show next content expanding from center + var center string + if centerEnd > centerStart { + truncatedNext := truncate.String(nextLine, uint(centerEnd)) + center = skip.String(truncatedNext, uint(centerStart)) + } + + line = leftPrev + center + rightPrev + line = truncate.String(line, uint(t.width)) + } + + s.WriteString(line) + if i < maxLines-1 { + s.WriteString("\n") + } + } + + return s.String() +} + +func (t expand) Name() string { + return "expand" +} + +func (t expand) Opposite() Transition { + return newCollapse(t.fps) +} + +func (t expand) Direction() direction { + return t.direction +} diff --git a/internal/tui/transitions/fade.go b/internal/tui/transitions/fade.go new file mode 100644 index 0000000..22c66b7 --- /dev/null +++ b/internal/tui/transitions/fade.go @@ -0,0 +1,205 @@ +package transitions + +import ( + "math" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + charmansi "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/truncate" + + "github.com/museslabs/kyma/internal/skip" +) + +type tileGrid struct { + tileSize int + gridWidth int + gridHeight int + tileOrder []int +} + +func newTileGrid(width, height, tileSize int) tileGrid { + gridWidth := (width + tileSize - 1) / tileSize + gridHeight := (height + tileSize - 1) / tileSize + + totalTiles := gridWidth * gridHeight + tileOrder := make([]int, totalTiles) + for i := range totalTiles { + tileOrder[i] = i + } + + r := rand.New(rand.NewSource(42)) + r.Shuffle(len(tileOrder), func(i, j int) { + tileOrder[i], tileOrder[j] = tileOrder[j], tileOrder[i] + }) + + return tileGrid{ + tileSize: tileSize, + gridWidth: gridWidth, + gridHeight: gridHeight, + tileOrder: tileOrder, + } +} + +func (g tileGrid) revealedTiles(progress float64) map[int]bool { + totalTiles := len(g.tileOrder) + revealedCount := int(math.Round(progress * float64(totalTiles))) + + revealedSet := make(map[int]bool) + for i := range revealedCount { + if i < len(g.tileOrder) { + revealedSet[g.tileOrder[i]] = true + } + } + return revealedSet +} + +func (g tileGrid) tileIndex(x, y int) int { + tileX := x / g.tileSize + tileY := y / g.tileSize + return tileY*g.gridWidth + tileX +} + +type fade struct { + width int + height int + fps int + spring harmonica.Spring + progress float64 + vel float64 + animating bool + direction direction + grid tileGrid +} + +func newFade(fps int) fade { + const frequency = 15 + const damping = 0.65 + + return fade{ + fps: fps, + spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), + } +} + +func (t fade) Start(width, height int, direction direction) Transition { + t.width = width + t.height = height + t.animating = true + t.progress = 0 + t.vel = 0 + t.direction = direction + t.grid = newTileGrid(width, height, 2) + + return t +} + +func (t fade) Animating() bool { + return t.animating +} + +func (t fade) Update() (Transition, tea.Cmd) { + targetProgress := 1.0 + + t.progress, t.vel = t.spring.Update(t.progress, t.vel, targetProgress) + + if t.progress >= 0.99 { + t.animating = false + t.progress = 1.0 + return t, nil + } + + return t, Animate(time.Duration(t.fps)) +} + +func (t fade) View(prev, next string) string { + var s strings.Builder + + prevLines := strings.Split(prev, "\n") + nextLines := strings.Split(next, "\n") + + // Ensure slides are equal height + maxLines := max(len(nextLines), len(prevLines)) + + // Get revealed tiles from grid + revealedSet := t.grid.revealedTiles(t.progress) + allRevealed := len(revealedSet) >= len(t.grid.tileOrder) + + for lineIdx := range maxLines { + var prevLine, nextLine string + + if lineIdx < len(prevLines) { + prevLine = prevLines[lineIdx] + } + if lineIdx < len(nextLines) { + nextLine = nextLines[lineIdx] + } + + var line string + if allRevealed { + line = truncate.String(nextLine, uint(t.width)) + } else { + line = t.buildFadeLine(prevLine, nextLine, revealedSet, lineIdx) + } + + s.WriteString(line) + if lineIdx < maxLines-1 { + s.WriteString("\n") + } + } + + return s.String() +} + +func (t fade) buildFadeLine(prevLine, nextLine string, revealedSet map[int]bool, lineIdx int) string { + var result strings.Builder + + for tileX := range t.grid.gridWidth { + startPos := tileX * t.grid.tileSize + endPos := min(startPos+t.grid.tileSize, t.width) + tileWidth := endPos - startPos + + tileIndex := t.grid.tileIndex(startPos, lineIdx) + + // Choose source line based on tile state + sourceLine := prevLine + if revealedSet[tileIndex] { + sourceLine = nextLine + } + + // Extract tile segment + segment := t.extractTileSegment(sourceLine, startPos, tileWidth) + result.WriteString(segment) + } + + finalLine := result.String() + if charmansi.StringWidth(finalLine) > t.width { + finalLine = truncate.String(finalLine, uint(t.width)) + } + + return finalLine +} + +func (t fade) extractTileSegment(line string, startPos, tileWidth int) string { + if startPos == 0 { + return truncate.String(line, uint(tileWidth)) + } + + skipped := skip.String(line, uint(startPos)) + return truncate.String(skipped, uint(tileWidth)) +} + +func (t fade) Name() string { + return "fade" +} + +func (t fade) Opposite() Transition { + return newFade(t.fps) +} + +func (t fade) Direction() direction { + return t.direction +} diff --git a/internal/tui/transitions/transition.go b/internal/tui/transitions/transition.go index 5b963d8..d60c074 100644 --- a/internal/tui/transitions/transition.go +++ b/internal/tui/transitions/transition.go @@ -45,6 +45,12 @@ func Get(name string, fps int) Transition { return newSwipeRight(fps) case "flip": return newFlipRight(fps) + case "collapse": + return newCollapse(fps) + case "expand": + return newExpand(fps) + case "fade": + return newFade(fps) default: return newNoTransition(fps) }