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/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 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("Roboto-Regular") if !ok { return fmt.Errorf("could not find application font: Roboto-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) } 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 { label += " [" + string(n.Security) + "]" } 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 { baseFontSize := p.Context.CfgPtr.StandardFontSize 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(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(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(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(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(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() }