mirror of
https://github.com/0x1d/rcond.git
synced 2025-12-16 10:54:29 +01:00
feat: basic configuration UI
This commit is contained in:
420
pkg/ui/ui.go
Normal file
420
pkg/ui/ui.go
Normal file
@@ -0,0 +1,420 @@
|
||||
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("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) 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
|
||||
}
|
||||
Reference in New Issue
Block a user