From d16db82cab375d2e00610454e75d7bd920626f24 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Wed, 28 May 2025 07:13:38 +0200 Subject: [PATCH] feat: basic configuration UI --- Makefile | 2 +- cmd/rcond/main.go | 32 ++-- config/rcond.yaml | 12 ++ go.mod | 26 +++ go.sum | 73 ++++++++ pkg/config/config.go | 33 ++++ pkg/ui/ui.go | 420 +++++++++++++++++++++++++++++++++++++++++++ tea.go | 179 ++++++++++++++++++ 8 files changed, 765 insertions(+), 12 deletions(-) create mode 100644 pkg/ui/ui.go create mode 100644 tea.go diff --git a/Makefile b/Makefile index 44f704c..613510e 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := bash ARCH ?= amd64 ADDR ?= 0.0.0.0:8080 -default: build +default: info .PHONY: info info: diff --git a/cmd/rcond/main.go b/cmd/rcond/main.go index b9a557c..b8c1839 100644 --- a/cmd/rcond/main.go +++ b/cmd/rcond/main.go @@ -9,6 +9,7 @@ import ( "github.com/0x1d/rcond/pkg/config" "github.com/0x1d/rcond/pkg/rcond" + "github.com/0x1d/rcond/pkg/ui" ) func usage() { @@ -24,9 +25,24 @@ func main() { os.Exit(1) } - rcond.NewNode(appConfig).Up() + switch appConfig.RunMode { + case config.RunModeNode: + fmt.Println("running node") + // Validate required fields + if err := validateRequiredFields(map[string]*string{ + "addr": &appConfig.Rcond.Addr, + "token": &appConfig.Rcond.ApiToken, + }); err != nil { + usage() + fmt.Printf("\nFailed to validate required fields: %v\n", err) + os.Exit(1) + } + rcond.NewNode(appConfig).Up() + select {} + case config.RunModeUI: + ui.NewUI(appConfig) + } - select {} } func loadConfig() (*config.Config, error) { @@ -36,6 +52,8 @@ func loadConfig() (*config.Config, error) { flag.StringVar(&configPath, "config", configPath, "Path to the configuration file") flag.BoolVar(&help, "help", false, "Show help") + // check for runmode + runMode := flag.String("runmode", "node", "Run mode: node or ui") flag.Parse() if help { @@ -51,15 +69,7 @@ func loadConfig() (*config.Config, error) { } appConfig = configFile } - - // Validate required fields - if err := validateRequiredFields(map[string]*string{ - "addr": &appConfig.Rcond.Addr, - "token": &appConfig.Rcond.ApiToken, - }); err != nil { - return nil, err - } - + appConfig.RunMode = config.RunMode(*runMode) return appConfig, nil } diff --git a/config/rcond.yaml b/config/rcond.yaml index 1af6200..4102740 100644 --- a/config/rcond.yaml +++ b/config/rcond.yaml @@ -4,6 +4,18 @@ rcond: # API token to use for authentication api_token: 1234567890 +wifi: + ap: + ssid: "rcond-ap" + password: "rcond-ap-password" + encryption: "WPA2" + interface: "wlan0" + sta: + ssid: "rcond-sta" + password: "rcond-sta-password" + encryption: "WPA2" + interface: "wlan1" + cluster: # Enable the cluster agent enabled: true diff --git a/go.mod b/go.mod index 3e4ece3..2259784 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,16 @@ replace github.com/0x1d/rcond/cmd => ./cmd replace github.com/0x1d/rcond/pkg => ./pkg require ( + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/huh v0.7.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/serf v0.10.2 github.com/kelseyhightower/envconfig v1.4.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.37.0 gopkg.in/yaml.v3 v3.0.1 @@ -20,7 +24,18 @@ require ( require ( github.com/armon/go-metrics v0.4.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/btree v1.1.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect @@ -31,12 +46,23 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/memberlist v0.5.2 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/miekg/dns v1.1.56 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/go.sum b/go.sum index 81aa3dc..34c6223 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -7,16 +9,58 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= +github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -96,13 +140,29 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= @@ -130,6 +190,9 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= @@ -137,6 +200,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -145,11 +210,15 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -179,12 +248,16 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= diff --git a/pkg/config/config.go b/pkg/config/config.go index 8aec0a8..f3a19bb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,17 +8,46 @@ import ( ) type Config struct { + RunMode RunMode `yaml:"runmode"` Hostname string `yaml:"hostname" envconfig:"HOSTNAME"` Rcond RcondConfig `yaml:"rcond"` Network NetworkConfig `yaml:"network"` + Wifi WifiConfig `yaml:"wifi"` Cluster ClusterConfig `yaml:"cluster"` + UI UIConfig `yaml:"ui"` } +type RunMode string + +const ( + RunModeNode RunMode = "node" + RunModeUI RunMode = "ui" +) + type RcondConfig struct { Addr string `yaml:"addr" envconfig:"RCOND_ADDR"` ApiToken string `yaml:"api_token" envconfig:"RCOND_API_TOKEN"` } +type WifiConfig struct { + AP APConfig `yaml:"ap"` + STA STAConfig `yaml:"sta"` +} + +type APConfig struct { + SSID string `yaml:"ssid" envconfig:"WIFI_AP_SSID"` + Password string `yaml:"password" envconfig:"WIFI_AP_PASSWORD"` + Encryption string `yaml:"encryption" envconfig:"WIFI_AP_ENCRYPTION"` + Interface string `yaml:"interface" envconfig:"WIFI_AP_INTERFACE"` +} + +type STAConfig struct { + SSID string `yaml:"ssid" envconfig:"WIFI_STA_SSID"` + Password string `yaml:"password" envconfig:"WIFI_STA_PASSWORD"` + Encryption string `yaml:"encryption" envconfig:"WIFI_STA_ENCRYPTION"` + Interface string `yaml:"interface" envconfig:"WIFI_STA_INTERFACE"` +} + type NetworkConfig struct { Connections []ConnectionConfig `yaml:"connections"` } @@ -50,6 +79,10 @@ type ClusterConfig struct { LogLevel string `yaml:"log_level" envconfig:"CLUSTER_LOG_LEVEL"` } +type UIConfig struct { + Enabled bool `yaml:"enabled" envconfig:"UI_ENABLED"` +} + // LoadConfig reads the configuration from a YAML file and environment variables. func LoadConfig(filename string) (*Config, error) { var config Config diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go new file mode 100644 index 0000000..cfd6189 --- /dev/null +++ b/pkg/ui/ui.go @@ -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 +} diff --git a/tea.go b/tea.go new file mode 100644 index 0000000..b496d8b --- /dev/null +++ b/tea.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type model struct { + Tabs []string + TabContent []string + activeTab int + form *huh.Form +} + +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.KeyMsg: + switch keypress := msg.String(); keypress { + case "ctrl+c", "q": + return m, tea.Quit + case "right", "l", "n", "tab": + m.activeTab = min(m.activeTab+1, len(m.Tabs)-1) + return m, nil + case "left", "h", "p", "shift+tab": + m.activeTab = max(m.activeTab-1, 0) + return m, nil + } + } + + 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) + } + + if m.form.State == huh.StateCompleted { + // Quit when the form is done. + cmds = append(cmds, tea.Quit) + } + + return m, tea.Batch(cmds...) +} + +func tabBorderWithBottom(left, middle, right string) lipgloss.Border { + border := lipgloss.RoundedBorder() + border.BottomLeft = left + border.Bottom = middle + border.BottomRight = right + return border +} + +var ( + inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") + activeTabBorder = tabBorderWithBottom("┘", " ", "└") + docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) + highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true).BorderForeground(highlightColor).Padding(0, 1) + activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true) + windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.NormalBorder()).UnsetBorderTop() +) + +func (m model) View() string { + doc := strings.Builder{} + + var renderedTabs []string + + for i, t := range m.Tabs { + var style lipgloss.Style + isFirst, isLast, isActive := i == 0, i == len(m.Tabs)-1, i == m.activeTab + if isActive { + style = activeTabStyle + } else { + style = inactiveTabStyle + } + border, _, _, _, _ := style.GetBorder() + if isFirst && isActive { + border.BottomLeft = "│" + } else if isFirst && !isActive { + border.BottomLeft = "├" + } else if isLast && isActive { + border.BottomRight = "│" + } else if isLast && !isActive { + border.BottomRight = "┤" + } + style = style.Border(border) + renderedTabs = append(renderedTabs, style.Render(t)) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) + doc.WriteString(row) + doc.WriteString("\n") + + if m.activeTab == 0 { + doc.WriteString(windowStyle.Width((lipgloss.Width(row) - windowStyle.GetHorizontalFrameSize())).Render(m.form.View())) + } + + return docStyle.Render(doc.String()) +} + +func main() { + tabs := []string{"Access Point Configuration"} + m := model{ + Tabs: tabs, + form: huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("ssid"). + Placeholder("Access Point SSID"). + Title("SSID"). + Description("The name of your access point"), + + 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 + }), + + huh.NewSelect[string](). + Key("encryption"). + Options(huh.NewOptions("WPA2", "WPA3", "WEP")...). + Title("Encryption"). + Description("The encryption method for your access point"), + + 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), + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}