package main import ( "fmt" "os" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/calmcacil/wg-admin/internal/config" "github.com/calmcacil/wg-admin/internal/tui/screens" ) 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")) styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color("46")) styleError = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) ) 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 initialized bool errorScreen *screens.ErrorScreen } func (m model) Init() tea.Cmd { if os.Geteuid() != 0 { fmt.Println(styleError.Render("ERROR: Must run as root")) os.Exit(1) } _, _ = config.LoadConfig() 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 { m.initialized = true m.currentScreen = screens.NewListScreen() 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 if msg.String() == "q" || msg.String() == "ctrl+c" { if _, ok := m.currentScreen.(*screens.ListScreen); ok { m.quitting = true return m, tea.Quit } } switch msg.String() { case "?": cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade) return m, cmd case "l": cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) return m, cmd case "a": cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft) return m, cmd } case screens.ClientSelectedMsg: cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft) return m, cmd case screens.ClientDeletedMsg: cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) return m, cmd case screens.ClientCreatedMsg: cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) return m, cmd case screens.RestoreCompletedMsg: cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) return m, cmd case screens.CloseDetailScreenMsg: if m.previousScreen != nil { cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight) return m, cmd } cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade) return m, cmd case screens.ErrMsg: m.previousScreen = m.currentScreen m.errorScreen = screens.NewErrorScreen(msg.Err) 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 newScreen == nil { if m.previousScreen != nil { transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight) return m, tea.Batch(cmd, transitionCmd) } } else if newScreen != m.currentScreen { m.previousScreen = m.currentScreen 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() } title := styleTitle.Render("WireGuard Client Manager") subtitle := styleSubtitle.Render(fmt.Sprintf("v%s", version)) bar := fmt.Sprintf("Press %s for help, %s to quit", styleHelpKey.Render("?"), styleHelpKey.Render("q")) return title + " " + subtitle + "\n\nInitializing...\n\n" + bar } } func main() { p := tea.NewProgram(model{}, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("Error: %v\n", err) 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 }