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 }