Files
wg-admin/cmd/wg-tui/main.go
2026-01-12 23:40:01 +01:00

386 lines
9.7 KiB
Go

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
}