Files
radiantwave-legacy/internal/page/wifiSetupPage.go
T
NeilClack 2735af5fbb refactor: remove cached AppConfig pointer and read configuration directly from DB
Replace in-memory atomic AppConfig caching with direct DB queries via db.GetConfig
across application and page layers. This simplifies state management and ensures
all components always operate on the latest persisted configuration.

Key changes:
- Remove Application.Config atomic cache and refreshCache mechanism
- Eliminate PageCtx.CfgPtr and pass DB handle instead
- Replace all cfg reads with db.GetConfig(app.DB)
- Update mixer, audio, UI pages, and initialization paths accordingly
- Explicitly set AppConfig table name to avoid GORM pluralization issues
- Reduce DB connection pooling (SetMaxIdleConns(1))
- Standardize application font to SpaceMonoNerdFont-Regular
- Lower default audio volume (1.0 → 0.2)
- Add Unicode checkmark glyph to font map

Behavioral impact:
- Configuration is no longer cached in memory
- Every config access now hits the DB (more consistent, slightly more overhead)
2026-06-26 22:51:32 -05:00

661 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
// 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."
}
}
// ─── Input ────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.TextInputEvent:
// SDL fires TextInputEvent for every printable character typed.
// This is the correct SDL2 way — no manual keycode mapping needed.
if p.isEnteringPassword {
p.passwordInput += e.GetText()
p.rebuildPasswordTexture()
}
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
if p.isEnteringPassword {
p.handlePasswordKey(e.Keysym.Sym)
} else {
p.handleNavKey(e.Keysym.Sym)
}
}
return nil
}
func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode) {
switch sym {
case sdl.K_RETURN:
p.submitPassword()
case sdl.K_BACKSPACE:
if len(p.passwordInput) > 0 {
// Trim one rune safely
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
}
}
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 = &selected
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.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()
}