From 1187ae004620c1c6066ac6c60d40ca0ca20c1b58 Mon Sep 17 00:00:00 2001 From: Calmcacil Date: Mon, 12 Jan 2026 23:40:01 +0100 Subject: [PATCH] Add screen transition animations for polished UX --- cmd/wg-tui/main.go | 313 ++++++++++++++++++++++++++---- internal/tui/screens/interface.go | 10 + 2 files changed, 283 insertions(+), 40 deletions(-) diff --git a/cmd/wg-tui/main.go b/cmd/wg-tui/main.go index cbd76f6..fd69e4c 100644 --- a/cmd/wg-tui/main.go +++ b/cmd/wg-tui/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -13,6 +14,16 @@ import ( const version = "0.1.0" +// TickMsg is sent for transition animation frames +type TickMsg time.Time + +// Transition settings +const ( + transitionDuration = 200 * time.Millisecond + transitionFPS = 60 +) + + var ( styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true) styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) @@ -22,6 +33,13 @@ var ( ) type model struct { + transitionFrom screens.Screen + transitionTo screens.Screen + transitionProgress float64 + transitionAnimating bool + transitionStartTime time.Time + transitionType screens.TransitionType + transitionDuration time.Duration currentScreen screens.Screen previousScreen screens.Screen quitting bool @@ -38,6 +56,8 @@ func (m model) Init() tea.Cmd { return nil } +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle initialization on first update after Init if !m.initialized { @@ -46,92 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.currentScreen.Init() } + // Handle transition animation updates + if m.transitionAnimating { + switch msg.(type) { + case TickMsg: + // Update transition progress + elapsed := time.Since(m.transitionStartTime) + m.transitionProgress = float64(elapsed) / float64(m.transitionDuration) + + // Ease-out cubic function for smoother animation + easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress)) + + if m.transitionProgress >= 1 || easeProgress >= 1 { + // Transition complete + m.transitionAnimating = false + m.currentScreen = m.transitionTo + m.previousScreen = m.transitionFrom + m.transitionFrom = nil + m.transitionTo = nil + return m, nil + } + + // Continue animation + return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg { + return TickMsg(t) + }) + } + // During transition, don't process other messages + return m, nil + } + switch msg := msg.(type) { case tea.KeyMsg: // Only quit on 'q' or ctrl+c when on list screen - // Other screens handle their own navigation keys if msg.String() == "q" || msg.String() == "ctrl+c" { if _, ok := m.currentScreen.(*screens.ListScreen); ok { m.quitting = true return m, tea.Quit } - // For other screens, let them handle the key } switch msg.String() { case "?": - // Switch to help screen - m.previousScreen = m.currentScreen - m.currentScreen = screens.NewHelpScreen(m.previousScreen) - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade) + return m, cmd case "l": - // Switch to list screen - m.currentScreen = screens.NewListScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) + return m, cmd case "a": - // Switch to add screen - m.previousScreen = m.currentScreen - m.currentScreen = screens.NewAddScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft) + return m, cmd } case screens.ClientSelectedMsg: - // User selected a client - show detail screen - m.previousScreen = m.currentScreen - m.currentScreen = screens.NewDetailScreen(msg.Client.Client) - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft) + return m, cmd case screens.ClientDeletedMsg: - // Client was deleted - show success message and return to list - m.currentScreen = screens.NewListScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) + return m, cmd case screens.ClientCreatedMsg: - // Client was created - return to list screen - m.currentScreen = screens.NewListScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) + return m, cmd case screens.RestoreCompletedMsg: - // Restore completed - return to list screen to refresh clients - m.currentScreen = screens.NewListScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) + return m, cmd case screens.CloseDetailScreenMsg: - // Detail screen closed - go back to previous screen if m.previousScreen != nil { - m.currentScreen = m.previousScreen - m.previousScreen = nil - return m, m.currentScreen.Init() + cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight) + return m, cmd } - m.currentScreen = screens.NewListScreen() - return m, m.currentScreen.Init() + cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) + return m, cmd case screens.ErrMsg: - // An error occurred - show error screen m.previousScreen = m.currentScreen m.errorScreen = screens.NewErrorScreen(msg.Err) - m.currentScreen = m.errorScreen - return m, m.currentScreen.Init() + cmd := m.startTransition(m.errorScreen, screens.TransitionFade) + return m, cmd } // Pass messages to current screen if m.currentScreen != nil { newScreen, cmd := m.currentScreen.Update(msg) - // If screen returns nil, go back to previous screen if newScreen == nil { if m.previousScreen != nil { - m.currentScreen = m.previousScreen - m.previousScreen = nil + transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight) + return m, tea.Batch(cmd, transitionCmd) } } else if newScreen != m.currentScreen { - // Screen is switching to a different screen m.previousScreen = m.currentScreen - m.currentScreen = newScreen + transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft) + return m, tea.Batch(cmd, transitionCmd) } return m, cmd } return m, nil } +} + +func (m model) View() string { func (m model) View() string { if m.quitting { return "\nGoodbye!\n" } + // If transitioning, render the transition animation + if m.transitionAnimating { + return renderTransition(m) + } + if m.currentScreen != nil && m.initialized { return m.currentScreen.View() } @@ -142,6 +184,7 @@ func (m model) View() string { return title + " " + subtitle + "\n\nInitializing...\n\n" + bar } +} func main() { p := tea.NewProgram(model{}, tea.WithAltScreen()) @@ -150,3 +193,193 @@ func main() { os.Exit(1) } } + +// startTransition begins a screen transition animation +func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd { + // Initialize new screen + var initCmd tea.Cmd + if toScreen != nil { + initCmd = toScreen.Init() + } + + // Set up transition + m.transitionFrom = m.currentScreen + m.transitionTo = toScreen + m.transitionType = ttype + m.transitionDuration = transitionDuration + m.transitionProgress = 0 + m.transitionAnimating = true + m.transitionStartTime = time.Now() + + // Return initialization command and tick command + tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg { + return TickMsg(t) + }) + + if initCmd != nil { + return tea.Batch(initCmd, tickCmd) + } + return tickCmd +} + +func renderTransition(m model) string { + if m.transitionFrom == nil || m.transitionTo == nil { + return m.transitionTo.View() + } + + fromView := m.transitionFrom.View() + toView := m.transitionTo.View() + + // Apply easing to progress + easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress)) + + switch m.transitionType { + case screens.TransitionFade: + return renderFadeTransition(fromView, toView, easeProgress) + case screens.TransitionSlideLeft: + return renderSlideTransition(fromView, toView, easeProgress, true) + case screens.TransitionSlideRight: + return renderSlideTransition(fromView, toView, easeProgress, false) + default: + return toView + } +} + +func renderFadeTransition(fromView, toView string, progress float64) string { + fromLines := splitLines(fromView) + toLines := splitLines(toView) + + maxLines := max(len(fromLines), len(toLines)) + for len(fromLines) < maxLines { + fromLines = append(fromLines, "") + } + for len(toLines) < maxLines { + toLines = append(toLines, "") + } + + var result []string + for i := 0; i < maxLines; i++ { + threshold := progress * float64(maxLines) + if float64(i) < threshold { + result = append(result, toLines[i]) + } else { + result = append(result, fromLines[i]) + } + } + + return joinLines(result) +} + +func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string { + fromLines := splitLines(fromView) + toLines := splitLines(toView) + + width := 80 + + for i := range fromLines { + fromLines[i] = padLine(fromLines[i], width) + } + for i := range toLines { + toLines[i] = padLine(toLines[i], width) + } + + offset := int(float64(width) * progress) + + var result []string + maxLines := max(len(fromLines), len(toLines)) + + for i := 0; i < maxLines; i++ { + var fromLine, toLine string + if i < len(fromLines) { + fromLine = fromLines[i] + } else { + fromLine = "" + } + if i < len(toLines) { + toLine = toLines[i] + } else { + toLine = "" + } + + if slideLeft { + fromOffset := -offset + toOffset := width - offset + combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width) + result = append(result, combined) + } else { + fromOffset := offset + toOffset := -width + offset + combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width) + result = append(result, combined) + } + } + + return joinLines(result) +} + +func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string { + result := make([]byte, width) + for i := range result { + result[i] = ' ' + } + + if fromOffset >= -len(from) && fromOffset < width { + for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ { + if i+fromOffset >= 0 && i+fromOffset < width { + result[i+fromOffset] = from[i] + } + } + } + + if toOffset >= -len(to) && toOffset < width { + for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ { + if i+toOffset >= 0 && i+toOffset < width { + result[i+toOffset] = to[i] + } + } + } + + return string(result) +} + +func splitLines(s string) []string { + lines := make([]string, 0) + current := "" + for _, c := range s { + if c == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(c) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func joinLines(lines []string) string { + result := "" + for i, line := range lines { + if i > 0 { + result += "\n" + } + result += line + } + return result +} + +func padLine(line string, width int) string { + if len(line) >= width { + return line[:width] + } + return fmt.Sprintf("%-*s", width, line) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/screens/interface.go b/internal/tui/screens/interface.go index 100a610..0488cb1 100644 --- a/internal/tui/screens/interface.go +++ b/internal/tui/screens/interface.go @@ -4,6 +4,16 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// TransitionType defines the type of transition animation +type TransitionType int + +const ( + TransitionNone TransitionType = iota + TransitionFade + TransitionSlideLeft + TransitionSlideRight +) + // Screen represents a UI screen (list, add, detail, etc.) type Screen interface { Init() tea.Cmd