386 lines
9.7 KiB
Go
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
|
|
}
|