Files
radiantwave-legacy/internal/page/wifiSetupPage.go
T
2026-06-27 16:51:18 -05:00

657 lines
17 KiB
Go

package page
import (
"context"
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/keybinds"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/network"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// ─── Types ────────────────────────────────────────────────────────────────────
type networkUIItem struct {
Net network.Network
Item StringItem
}
type wifiStatus int
const (
wifiStatusIdle wifiStatus = iota
wifiStatusScanning
wifiStatusConnecting
wifiStatusConnected
wifiStatusError
)
// ─── Page ─────────────────────────────────────────────────────────────────────
type WiFiSetupPage struct {
Base
applicationFont *fontManager.FontEntry
textInputSupported bool
// UI elements
BaseFontSize int
titleItem StringItem
scanningStatusItem StringItem
connectionStatusItem StringItem
passwordPromptItem StringItem
passwordInputItem StringItem
passwordErrorItem StringItem
// Network list
networks []networkUIItem
selectedIndex int
// State
status wifiStatus
statusMessage string
connectError string
// Password entry
isEnteringPassword bool
passwordInput string
selectedNet *network.Network
// Async
ctx context.Context
cancel context.CancelFunc
networksCh <-chan []network.Network
connectResultCh chan error
}
// ─── Init ─────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("could not find application font: SpaceMonoNerdFont-Regular")
}
p.applicationFont = font
var err error
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White)
if err != nil {
return fmt.Errorf("failed to create title item: %w", err)
}
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White)
if err != nil {
return fmt.Errorf("failed to create scanning status item: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
p.BaseFontSize = config.StandardFontSize
p.connectResultCh = make(chan error, 1)
p.ctx, p.cancel = context.WithCancel(context.Background())
p.startScan()
return nil
}
// ─── Scan ─────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) startScan() {
p.status = wifiStatusScanning
p.statusMessage = "Scanning for networks..."
p.selectedIndex = 0
p.networks = nil
var err error
p.networksCh, err = network.ScanForNetworks(p.ctx)
if err != nil {
logger.Error("wifi: failed to start scan: %v", err)
p.status = wifiStatusError
p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found."
p.rebuildStatusTexture()
}
}
// ─── Input ────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
if p.isEnteringPassword {
p.handlePasswordKey(e.Keysym.Sym, e.Keysym.Mod)
} else {
p.handleNavKey(e.Keysym.Sym)
}
}
return nil
}
func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode, mod uint16) {
switch sym {
case sdl.K_RETURN:
p.submitPassword()
case sdl.K_BACKSPACE:
if len(p.passwordInput) > 0 {
runes := []rune(p.passwordInput)
p.passwordInput = string(runes[:len(runes)-1])
p.rebuildPasswordTexture()
}
case sdl.K_ESCAPE:
logger.Info("wifi: password entry cancelled")
p.exitPasswordMode()
p.connectError = ""
p.selectedNet = nil
default:
if ch := keycodeToChar(sym, mod); ch != 0 {
p.passwordInput += string(ch)
p.rebuildPasswordTexture()
}
}
}
func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) {
switch sym {
case sdl.K_UP:
if p.selectedIndex > 0 {
p.selectedIndex--
}
case sdl.K_DOWN:
if p.selectedIndex < len(p.networks)-1 {
p.selectedIndex++
}
case sdl.K_RETURN:
if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) {
return
}
selected := p.networks[p.selectedIndex].Net
p.selectedNet = &p.networks[p.selectedIndex].Net
p.connectError = ""
if selected.Security != network.SecurityOpen {
p.passwordInput = ""
p.enterPasswordMode()
} else {
p.connect("")
}
case sdl.K_r:
if p.status != wifiStatusScanning && p.status != wifiStatusConnecting {
p.startScan()
}
case sdl.K_ESCAPE:
p.selectedNet = nil
p.connectError = ""
}
}
func (p *WiFiSetupPage) enterPasswordMode() {
keybinds.Enabled = false
p.isEnteringPassword = true
p.textInputSupported = false
p.rebuildPasswordPromptTexture()
p.rebuildPasswordTexture()
sdl.StartTextInput()
logger.Info("wifi: password entry started for %q", p.selectedNet.SSID)
}
func (p *WiFiSetupPage) exitPasswordMode() {
keybinds.Enabled = true
p.isEnteringPassword = false
sdl.StopTextInput()
logger.Info("wifi: password entry finished")
}
func (p *WiFiSetupPage) submitPassword() {
p.exitPasswordMode()
p.connect(p.passwordInput)
}
// ─── Connect ──────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) connect(password string) {
if p.selectedNet == nil {
return
}
ssid := p.selectedNet.SSID
p.status = wifiStatusConnecting
p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid)
logger.Info("wifi: connecting to %q", ssid)
// ConnectToNetwork blocks (up to 15s), so run it off the main goroutine.
go func() {
err := network.ConnectToNetwork(ssid, password)
p.connectResultCh <- err
}()
}
// ─── Update ───────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Update(dt float32) error {
select {
case nets, ok := <-p.networksCh:
if ok {
p.rebuildNetworkList(nets)
if p.status == wifiStatusScanning {
p.status = wifiStatusIdle
p.statusMessage = ""
}
}
case err := <-p.connectResultCh:
if err != nil {
logger.Error("wifi: connection failed: %v", err)
p.status = wifiStatusError
p.connectError = fmt.Sprintf("Could not connect: %s", err.Error())
p.statusMessage = ""
// Drop back into password entry so the user can try again.
if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen {
p.passwordInput = ""
p.rebuildPasswordErrorTexture()
p.enterPasswordMode()
}
} else {
ssid := ""
if p.selectedNet != nil {
ssid = p.selectedNet.SSID
}
logger.Info("wifi: connected to %q", ssid)
p.status = wifiStatusConnected
p.connectError = ""
p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid)
// Trigger a fresh scan so the list shows the connected state.
p.startScan()
}
default:
}
p.rebuildStatusTexture()
return nil
}
// ─── Texture builders ─────────────────────────────────────────────────────────
func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) {
for _, item := range p.networks {
if item.Item.ID != 0 {
gl.DeleteTextures(1, &item.Item.ID)
}
}
p.networks = make([]networkUIItem, 0, len(nets))
for _, n := range nets {
label := n.SSID
switch {
case n.Connected:
label += " ✓"
case n.Saved:
label += " ★"
}
if n.Security != network.SecurityOpen {
var secStr string
switch n.Security {
case network.SecurityWEP:
secStr = "WEP"
case network.SecurityWPA:
secStr = "WPA"
case network.SecurityWPA2:
secStr = "WPA2"
case network.SecurityWPA3:
secStr = "WPA3"
default:
secStr = "Secured"
}
label += " [" + secStr + "]"
}
c := colors.White
if n.Connected {
c = colors.LawnGreen
}
item, err := NewStringItem(label, p.applicationFont, c)
if err != nil {
logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err)
continue
}
p.networks = append(p.networks, networkUIItem{Net: n, Item: item})
}
}
func (p *WiFiSetupPage) rebuildStatusTexture() {
if p.connectionStatusItem.ID != 0 {
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
p.connectionStatusItem.ID = 0
}
var msg string
var c sdl.Color
switch p.status {
case wifiStatusScanning:
msg = "Scanning for nearby networks, please wait..."
c = colors.White
case wifiStatusConnecting:
msg = p.statusMessage
c = colors.Yellow
case wifiStatusConnected:
msg = p.statusMessage
c = colors.LawnGreen
case wifiStatusError:
msg = p.connectError
if msg == "" {
msg = "Something went wrong. Press R to scan again."
}
c = colors.Red
default:
if len(p.networks) == 0 {
msg = "No networks found. Press R to scan again."
c = colors.Yellow
} else {
msg = "Select a network to connect — press R to rescan"
c = colors.White
}
}
if msg == "" {
return
}
item, err := NewStringItem(msg, p.applicationFont, c)
if err == nil {
p.connectionStatusItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordTexture() {
if p.passwordInputItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordInputItem.ID)
}
text := p.passwordInput
if text == "" {
text = " " // prevent empty texture
}
item, err := NewStringItem(text, p.applicationFont, colors.White)
if err == nil {
p.passwordInputItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordPromptTexture() {
if p.passwordPromptItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
}
if p.selectedNet == nil {
return
}
prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID)
item, err := NewStringItem(prompt, p.applicationFont, colors.White)
if err == nil {
p.passwordPromptItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordErrorTexture() {
if p.passwordErrorItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
p.passwordErrorItem.ID = 0
}
if p.connectError == "" {
return
}
item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red)
if err == nil {
p.passwordErrorItem = item
}
}
// ─── Render ───────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Render() error {
solidShader, _ := shaderManager.Get().Get("solid_color")
textShader, _ := shaderManager.Get().Get("text")
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
white := colors.White
vertMargin := float32(20)
// Background
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{0, 0},
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
bgColor,
)
// Title — 48pt
titleScale := float32(48) / float32(p.BaseFontSize)
titleW := float32(p.titleItem.W) * titleScale
titleH := float32(p.titleItem.H) * titleScale
titleX := float32(p.ScreenCenterX) - titleW/2
titleY := float32(p.ScreenHeight) - titleH - 40
p.Base.RenderTexture(textShader, p.titleItem.ID,
mgl32.Vec2{titleX, titleY},
mgl32.Vec2{titleW, titleH},
white,
)
// Status message — 30pt (was 24pt, now 1.25x)
statusY := titleY - titleH - vertMargin
if p.status == wifiStatusScanning {
scale := float32(30) / float32(p.BaseFontSize)
w := float32(p.scanningStatusItem.W) * scale
h := float32(p.scanningStatusItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID,
mgl32.Vec2{x, statusY},
mgl32.Vec2{w, h},
white,
)
} else if p.connectionStatusItem.ID != 0 {
scale := float32(30) / float32(p.BaseFontSize)
w := float32(p.connectionStatusItem.W) * scale
h := float32(p.connectionStatusItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID,
mgl32.Vec2{x, statusY},
mgl32.Vec2{w, h},
p.connectionStatusItem.Color,
)
}
// Network list
listY := statusY - vertMargin
itemH := int32(40)
listPad := int32(10)
listW := float32(700)
listX := float32(p.ScreenCenterX) - listW/2
for i, item := range p.networks {
rowY := listY - float32(int32(i+1)*itemH)
if i == p.selectedIndex {
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{listX, rowY},
mgl32.Vec2{listW, float32(itemH)},
selColor,
)
}
scale := float32(24) / float32(p.BaseFontSize)
tw := float32(item.Item.W) * scale
th := float32(item.Item.H) * scale
tx := listX + float32(listPad)
ty := rowY + (float32(itemH)/2 - th/2)
p.Base.RenderTexture(textShader, item.Item.ID,
mgl32.Vec2{tx, ty},
mgl32.Vec2{tw, th},
item.Item.Color,
)
}
// Password dialog
if p.isEnteringPassword {
if err := p.renderPasswordDialog(solidShader, textShader, float32(p.BaseFontSize)); err != nil {
return err
}
}
return nil
}
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180}
dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255}
boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255}
boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255}
white := colors.White
dialogW := int32(600)
dialogH := int32(250)
if p.connectError != "" {
dialogH = 310
}
dialogX := p.ScreenCenterX - dialogW/2
dialogY := p.ScreenCenterY - dialogH/2
// Overlay + dialog box
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{0, 0},
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
overlayColor,
)
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(dialogX), float32(dialogY)},
mgl32.Vec2{float32(dialogW), float32(dialogH)},
dialogBgColor,
)
currentY := float32(dialogY+dialogH) - 30
// Error message
if p.passwordErrorItem.ID != 0 {
scale := float32(20) / baseFontSize
w := float32(p.passwordErrorItem.W) * scale
h := float32(p.passwordErrorItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
currentY -= h
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID,
mgl32.Vec2{x, currentY},
mgl32.Vec2{w, h},
colors.Red,
)
currentY -= 20
}
// Prompt
if p.passwordPromptItem.ID != 0 {
scale := float32(24) / baseFontSize
w := float32(p.passwordPromptItem.W) * scale
h := float32(p.passwordPromptItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
currentY -= h
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID,
mgl32.Vec2{x, currentY},
mgl32.Vec2{w, h},
white,
)
currentY -= 30
}
// Input box
padding := int32(4)
border := int32(3)
boxW := int32(500)
fontSize := int32(36)
boxH := fontSize + padding*2
bboxW := boxW + border*2
bboxH := boxH + border*2
bboxX := p.ScreenCenterX - bboxW/2
bboxY := int32(currentY) - boxH - padding*2
boxX := p.ScreenCenterX - boxW/2
boxY := int32(currentY) - boxH - padding
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(bboxX), float32(bboxY)},
mgl32.Vec2{float32(bboxW), float32(bboxH)},
boxBorderColor,
)
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(boxX), float32(boxY)},
mgl32.Vec2{float32(boxW), float32(boxH)},
boxBgColor,
)
// Password text (plain, unmasked)
if p.passwordInputItem.ID != 0 {
scale := float32(fontSize) / baseFontSize
w := float32(p.passwordInputItem.W) * scale
h := float32(p.passwordInputItem.H) * scale
p.Base.RenderTexture(textShader, p.passwordInputItem.ID,
mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)},
mgl32.Vec2{w, h},
white,
)
}
return nil
}
// ─── Destroy ──────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Destroy() error {
keybinds.Enabled = true
sdl.StopTextInput()
// Cancel the scan context — stops ScanForNetworks goroutine.
if p.cancel != nil {
p.cancel()
}
deleteTexture := func(id *uint32) {
if *id != 0 {
gl.DeleteTextures(1, id)
*id = 0
}
}
deleteTexture(&p.titleItem.ID)
deleteTexture(&p.scanningStatusItem.ID)
deleteTexture(&p.connectionStatusItem.ID)
deleteTexture(&p.passwordPromptItem.ID)
deleteTexture(&p.passwordInputItem.ID)
deleteTexture(&p.passwordErrorItem.ID)
for _, item := range p.networks {
deleteTexture(&item.Item.ID)
}
return p.Base.Destroy()
}