Files
rcond/pkg/ui/ui.go
2025-08-18 13:52:00 +02:00

422 lines
10 KiB
Go

package ui
import (
"fmt"
"image/color"
"log"
"strings"
"github.com/0x1d/rcond/pkg/config"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/skip2/go-qrcode"
)
const maxWidth = 120
type UI struct {
appConfig *config.Config
program *tea.Program
}
type UIConfig struct {
Enabled bool `yaml:"enabled" envconfig:"UI_ENABLED"`
}
var (
red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"}
indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
)
type Styles struct {
Base,
HeaderText,
Status,
StatusHeader,
Highlight,
ErrorHeaderText,
Help lipgloss.Style
}
func NewStyles(lg *lipgloss.Renderer) *Styles {
s := Styles{}
s.Base = lg.NewStyle().
Padding(1, 4, 0, 1)
s.HeaderText = lg.NewStyle().
Foreground(indigo).
Bold(true).
Padding(0, 1, 0, 2)
s.Status = lg.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(indigo).
PaddingLeft(1).
MarginTop(1)
s.StatusHeader = lg.NewStyle().
Foreground(green).
Bold(true)
s.Highlight = lg.NewStyle().
Foreground(lipgloss.Color("212"))
s.ErrorHeaderText = s.HeaderText.
Foreground(red)
s.Help = lg.NewStyle().
Foreground(lipgloss.Color("240"))
return &s
}
type state int
const (
statusNormal state = iota
stateModeSelection
stateDone
)
type Model struct {
state state
lg *lipgloss.Renderer
styles *Styles
form *huh.Form
width int
appConfig *config.Config
}
func NewModel(appConfig *config.Config) Model {
m := Model{width: maxWidth, state: stateModeSelection, appConfig: appConfig}
m.lg = lipgloss.DefaultRenderer()
m.styles = NewStyles(m.lg)
m.form = m.createModeForm()
return m
}
func (m Model) createModeForm() *huh.Form {
return huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("mode").
Options(huh.NewOptions("AP", "STA")...).
Title("Parameters").
Description("What do you want to configure?"),
),
).
WithWidth(45).
WithShowHelp(false).
WithShowErrors(false)
}
func min(x, y int) int {
if x > y {
return y
}
return x
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize()
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Interrupt
case "esc", "q":
return m, tea.Quit
}
}
var cmds []tea.Cmd
// Process the form
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
cmds = append(cmds, cmd)
}
// Handle mode selection
if m.state == stateModeSelection && m.form.State == huh.StateCompleted {
mode := m.form.GetString("mode")
if mode == "AP" {
m.form = m.createAPForm()
m.state = statusNormal
return m, m.form.Init()
} else if mode == "STA" {
m.form = m.createSTAForm()
m.state = statusNormal
return m, m.form.Init()
}
}
// After form completion, show mode selection again
if m.state == statusNormal && m.form.State == huh.StateCompleted {
m.appConfig.Wifi.AP.SSID = m.form.GetString("ssid")
m.appConfig.Wifi.AP.Password = m.form.GetString("password")
m.appConfig.Wifi.AP.Encryption = m.form.GetString("encryption")
m.form = m.createModeForm()
m.state = stateModeSelection
return m, m.form.Init()
}
return m, tea.Batch(cmds...)
}
func (m Model) createAPForm() *huh.Form {
if m.appConfig == nil {
log.Fatal("appConfig is nil")
return nil // or handle the error appropriately
}
return huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("ssid").
Placeholder("Access Point SSID").
Title("SSID").
Description("The name of your access point").
Value(&m.appConfig.Wifi.AP.SSID),
huh.NewInput().
Key("password").
Placeholder("Access Point Password").
Title("Password").
EchoMode(huh.EchoModePassword).
Description("The password for your access point").
Validate(func(v string) error {
if len(v) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
return nil
}).
Value(&m.appConfig.Wifi.AP.Password),
huh.NewSelect[string]().
Key("encryption").
Options(huh.NewOptions("WPA2", "WPA3", "WEP")...).
Title("Encryption").
Description("The encryption method for your access point").
Value(&m.appConfig.Wifi.AP.Encryption),
huh.NewConfirm().
Key("save").
Title("Apply configuration").
Validate(func(v bool) error {
if !v {
m.state = stateModeSelection
return nil
}
return nil
}).
Affirmative("Yep").
Negative("Nope"),
),
).
WithWidth(45).
WithShowHelp(false).
WithShowErrors(false)
}
func (m Model) createSTAForm() *huh.Form {
return huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("ssid").
Placeholder("Station SSID").
Title("SSID").
Description("The name of the station").
Value(&m.appConfig.Wifi.STA.SSID),
huh.NewInput().
Key("password").
Placeholder("Station Password").
Title("Password").
EchoMode(huh.EchoModePassword).
Description("The password for the station").
Validate(func(v string) error {
if len(v) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
return nil
}).
Value(&m.appConfig.Wifi.STA.Password),
huh.NewConfirm().
Key("done").
Title("All done?").
Validate(func(v bool) error {
if !v {
return fmt.Errorf("Welp, finish up then")
}
return nil
}).
Affirmative("Yep").
Negative("Wait, no"),
),
).
WithWidth(45).
WithShowHelp(false).
WithShowErrors(false)
}
func (m Model) View() string {
s := m.styles
switch m.state {
case stateModeSelection:
v := strings.TrimSuffix(m.form.View(), "\n\n")
form := m.lg.NewStyle().Margin(1, 0).Render(v)
header := m.appBoundaryView("Configuration")
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
return s.Base.Render(header + "\n" + form + "\n\n" + footer)
case statusNormal:
switch m.form.State {
case huh.StateCompleted:
ssid := m.appConfig.Wifi.STA.SSID
password := m.appConfig.Wifi.STA.Password
encryption := m.appConfig.Wifi.STA.Encryption
qrCode, err := qrcode.New("WIFI:S:"+ssid+";T:"+encryption+";P:"+password+";;", qrcode.Medium)
if err != nil {
log.Fatal(err)
}
qrCode.DisableBorder = true
qrCodeImage := qrCode.ToSmallString(false)
var b strings.Builder
fmt.Fprintf(&b, "Congratulations, you've configured your access point!\n\n")
fmt.Fprintf(&b, "SSID: %s\nEncryption: %s\n\nPlease proceed to connect to your access point.\n\n", ssid, encryption)
// Center the QR code
centeredQRCode := lipgloss.NewStyle().Render(qrCodeImage)
b.WriteString(centeredQRCode)
return s.Status.Margin(0, 1).Padding(2, 2).Width(48).Render(b.String()) + "\n\n"
default:
var ssid string
var newSsid, newPassword, newEncryption string
var encryption string
var password string
var qrCodeImage string
ssid = m.appConfig.Wifi.STA.SSID
password = m.appConfig.Wifi.STA.Password
encryption = m.appConfig.Wifi.STA.Encryption
newSsid = m.form.GetString("ssid")
if ssid != newSsid && newSsid != "" {
ssid = newSsid
}
newPassword = m.form.GetString("password")
if password != newPassword && newPassword != "" {
password = newPassword
}
newEncryption = m.form.GetString("encryption")
if encryption != newEncryption && newEncryption != "" {
encryption = newEncryption
}
if ssid != "" && password != "" && encryption != "" {
qrCode, err := qrcode.New("WIFI:S:"+ssid+";T:"+encryption+";P:"+password+";;", qrcode.Medium)
qrCode.BackgroundColor = color.White
qrCode.DisableBorder = false
if err != nil {
log.Fatal(err)
}
qrCodeImage = qrCode.ToSmallString(false)
}
// Form (left side)
v := strings.TrimSuffix(m.form.View(), "\n\n")
form := m.lg.NewStyle().Margin(1, 0).Render(v)
// Status (right side)
var status string
{
var (
buildInfo = "(None)"
)
if ssid != "" {
buildInfo = "SSID: " + ssid
}
if password != "" {
buildInfo += "\n" + "Password: " + strings.Repeat("*", len(password))
}
if encryption != "" {
buildInfo += "\n" + "Encryption: " + encryption
}
if qrCodeImage != "" {
buildInfo += "\n\n" + qrCodeImage
}
const statusWidth = 50
statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight()
status = s.Status.
Height(lipgloss.Height(form)).
Width(statusWidth).
MarginLeft(statusMarginLeft).
Render(s.StatusHeader.Render("Current Configuration") + "\n" +
buildInfo)
}
errors := m.form.Errors()
header := m.appBoundaryView("Node Configuration")
if len(errors) > 0 {
header = m.appErrorBoundaryView(m.errorView())
}
body := lipgloss.JoinHorizontal(lipgloss.Left, form, status)
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
if len(errors) > 0 {
footer = m.appErrorBoundaryView("")
}
return s.Base.Render(header + "\n" + body + "\n\n" + footer)
}
}
return ""
}
func (m Model) errorView() string {
var s string
for _, err := range m.form.Errors() {
s += err.Error()
}
return s
}
func (m Model) appBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.styles.HeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(indigo),
)
}
func (m Model) appErrorBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.styles.ErrorHeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(red),
)
}
func NewUI(appConfig *config.Config) *UI {
// Initialize our program
ui := &UI{appConfig: appConfig}
fmt.Printf("SSID: %s", appConfig.Wifi.AP.SSID)
ui.program = tea.NewProgram(NewModel(appConfig), tea.WithAltScreen())
if _, err := ui.program.Run(); err != nil {
log.Fatal(err)
}
return ui
}