initial commit
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"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/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
|
||||
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
|
||||
const focusToggle = -1
|
||||
|
||||
// AudioFileList is kept as the canonical seed data for the audio_files table.
|
||||
// Track selection UI no longer lives on this page, but other parts of the
|
||||
// app still read these rows from the database.
|
||||
var AudioFileList = []db.AudioFile{
|
||||
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
|
||||
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
|
||||
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
|
||||
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
|
||||
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
|
||||
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
|
||||
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
|
||||
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
|
||||
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
|
||||
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
|
||||
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
|
||||
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
|
||||
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
|
||||
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
|
||||
}
|
||||
|
||||
const triangleSize = float32(16)
|
||||
|
||||
type AudioDevice struct {
|
||||
Label string
|
||||
ID uint32
|
||||
index int
|
||||
}
|
||||
|
||||
type AudioOptions struct {
|
||||
// Page Attributes
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
CfgPtr *db.AppConfig
|
||||
|
||||
// Page Textures
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
// Layout
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
|
||||
// Audio toggle
|
||||
AudioStatusToggle StringItem
|
||||
|
||||
// Audio Devices
|
||||
activeDeviceIndex int
|
||||
NumAudioDevices int
|
||||
AudioDevices []AudioDevice
|
||||
AudioDeviceTextures []StringItem
|
||||
|
||||
// Volume row
|
||||
VolumeLabel StringItem
|
||||
volumePercent StringItem
|
||||
volumeBarX int32
|
||||
volumeBarY int32
|
||||
volumeBarW int32
|
||||
volumeBarH int32
|
||||
|
||||
// Triangle cursor
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
spinElapsed float32
|
||||
|
||||
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
|
||||
focusState int
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.focusState = focusToggle
|
||||
|
||||
// Font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Shaders
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
if err = p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
baseFontSize := config.StandardFontSize
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||
|
||||
// Title
|
||||
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
// Prompt
|
||||
p.prompt, err = NewStringItem(
|
||||
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
|
||||
p.font, colors.White,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(p.ItemScale)
|
||||
|
||||
// Volume row
|
||||
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating volume label: %w", err)
|
||||
}
|
||||
p.VolumeLabel.Scale(p.ItemScale)
|
||||
if err = p.CreateVolumePercentItem(); err != nil {
|
||||
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
|
||||
}
|
||||
|
||||
// Audio toggle
|
||||
if err = p.CreateAudioStatusItem(); err != nil {
|
||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||
}
|
||||
|
||||
// Audio output devices
|
||||
if err = p.GetAudioDevices(); err != nil {
|
||||
logger.Error("failed to enumerate audio devices: %v", err)
|
||||
}
|
||||
|
||||
// Set active device index based on mixer's current device
|
||||
currentName := mixer.CurrentDevice()
|
||||
config, err = db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p.activeDeviceIndex = -1 // default to not found
|
||||
for i, dev := range p.AudioDevices {
|
||||
if dev.Label == currentName {
|
||||
p.activeDeviceIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// If not found and current device is default (""), check saved config
|
||||
if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" {
|
||||
// Default device is selected - first device in SDL list is typically the default
|
||||
if len(p.AudioDevices) > 0 {
|
||||
p.activeDeviceIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the audio_files table is populated.
|
||||
if err = p.seedAudioFiles(); err != nil {
|
||||
logger.Error("failed to seed audio files: %v", err)
|
||||
}
|
||||
|
||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedAudioFiles ensures the audio_files table has its default rows.
|
||||
func (p *AudioOptions) seedAudioFiles() error {
|
||||
var existing []db.AudioFile
|
||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return p.Context.DbPtr.Save(&AudioFileList).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio status toggle ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
on := config.AudioEnabled
|
||||
statusString := "Turn Audio On"
|
||||
color := colors.Green
|
||||
if on {
|
||||
statusString = "Turn Audio Off"
|
||||
color = colors.Red
|
||||
}
|
||||
item, err := NewStringItem(statusString, p.font, color)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.AudioStatusToggle = item
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio Output Devices ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) GetAudioDevices() error {
|
||||
numAudioDevices := sdl.GetNumAudioDevices(false)
|
||||
if numAudioDevices == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pre-allocate with zero length so append doesn't produce leading zero-value
|
||||
// elements (the old code used make([]AudioDevice, n) then appended, which
|
||||
// doubled the slice length with n zero-value entries at the front).
|
||||
audioDevices := make([]AudioDevice, 0, numAudioDevices)
|
||||
for i := range numAudioDevices {
|
||||
label := sdl.GetAudioDeviceName(i, false)
|
||||
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
|
||||
}
|
||||
p.AudioDevices = audioDevices
|
||||
|
||||
for i := range audioDevices {
|
||||
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
|
||||
}
|
||||
p.NumAudioDevices = numAudioDevices
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectAudioDevice is called when the user confirms a device from the list.
|
||||
// The device-switching implementation goes here; for now we just log the choice.
|
||||
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
|
||||
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
|
||||
return fmt.Errorf("invalid device index: %d", deviceIndex)
|
||||
}
|
||||
device := p.AudioDevices[deviceIndex]
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config for device switch: %w", err)
|
||||
}
|
||||
|
||||
if err := mixer.SwitchDevice(device.Label, config); err != nil {
|
||||
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
|
||||
}
|
||||
|
||||
// Persist the selected device to the database
|
||||
config.SelectedAudioDevice = device.Label
|
||||
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
||||
logger.Error("failed to persist device selection: %v", err)
|
||||
}
|
||||
|
||||
p.activeDeviceIndex = deviceIndex
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) CreateVolumePercentItem() error {
|
||||
if p.volumePercent.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pct := int(config.SavedVolume * 100)
|
||||
item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create volume percent item: %v", err)
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.volumePercent = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) UpdateVolume(level int) {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newVol := mixer.ConvertVolume(level) + config.SavedVolume
|
||||
if newVol < 0.0 || newVol > 1.0 {
|
||||
return
|
||||
}
|
||||
if newVol > .99 {
|
||||
newVol = 1.0
|
||||
}
|
||||
config.SavedVolume = newVol
|
||||
p.Context.DbPtr.Save(&config)
|
||||
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
|
||||
logger.Error("Error setting allSinkVolumes: %v", err)
|
||||
}
|
||||
if err := p.CreateVolumePercentItem(); err != nil {
|
||||
logger.Error("failed to refresh volume percent item: %v", err)
|
||||
}
|
||||
gap := int32(12)
|
||||
p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap
|
||||
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) layout() {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Title
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
// Prompt
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
||||
|
||||
// Volume row
|
||||
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
|
||||
barW := int32(float32(p.ScreenWidth) * 0.30)
|
||||
barX := p.ScreenCenterX - barW/2
|
||||
barY := p.prompt.Y - p.verticalMargin*2 - barH
|
||||
|
||||
p.volumeBarX = barX
|
||||
p.volumeBarY = barY
|
||||
p.volumeBarW = barW
|
||||
p.volumeBarH = barH
|
||||
|
||||
p.VolumeLabel.X = barX - p.VolumeLabel.W - 20
|
||||
p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2
|
||||
|
||||
gap := int32(12)
|
||||
p.volumePercent.X = barX + barW + gap
|
||||
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
|
||||
|
||||
// Audio toggle — centred below the volume row
|
||||
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
||||
|
||||
// Device list — each item centred and stacked below the toggle
|
||||
nextY := p.AudioStatusToggle.Y - p.verticalMargin
|
||||
for i := range p.AudioDeviceTextures {
|
||||
item := &p.AudioDeviceTextures[i]
|
||||
item.X = p.ScreenCenterX - item.W/2
|
||||
item.Y = nextY - item.H
|
||||
nextY = item.Y - p.verticalMargin
|
||||
}
|
||||
|
||||
// Reposition the triangle cursor for whatever row currently has focus
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
// updateCursorPosition moves triX / triCenterY to sit just left of the
|
||||
// focused row. Call this any time focusState changes or layout() reruns.
|
||||
func (p *AudioOptions) updateCursorPosition() {
|
||||
cursorGap := int32(12)
|
||||
|
||||
var item *StringItem
|
||||
switch {
|
||||
case p.focusState == focusToggle:
|
||||
item = &p.AudioStatusToggle
|
||||
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
|
||||
item = &p.AudioDeviceTextures[p.focusState]
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) ToggleAudio() {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
logger.Error("panic! Cannot query database for configuration")
|
||||
panic(err)
|
||||
}
|
||||
config.AudioEnabled = !config.AudioEnabled
|
||||
p.Context.DbPtr.Save(config)
|
||||
p.CreateAudioStatusItem()
|
||||
p.layout()
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
||||
if !ok || e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_RETURN:
|
||||
if p.focusState == focusToggle {
|
||||
p.ToggleAudio()
|
||||
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
|
||||
if err := p.SelectAudioDevice(p.focusState); err != nil {
|
||||
logger.Error("SelectAudioDevice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.focusState == focusToggle {
|
||||
if len(p.AudioDeviceTextures) > 0 {
|
||||
p.focusState = 0
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
|
||||
p.focusState++
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_UP:
|
||||
if p.focusState == 0 {
|
||||
p.focusState = focusToggle
|
||||
p.updateCursorPosition()
|
||||
} else if p.focusState > 0 {
|
||||
p.focusState--
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_RIGHT:
|
||||
p.UpdateVolume(10)
|
||||
|
||||
case sdl.K_LEFT:
|
||||
p.UpdateVolume(-10)
|
||||
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) renderVolumeBar() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
vol := config.SavedVolume
|
||||
|
||||
fillW := float32(p.volumeBarW) * float32(vol)
|
||||
if fillW > 0 {
|
||||
if err := p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)},
|
||||
mgl32.Vec2{fillW, float32(p.volumeBarH)},
|
||||
colors.White,
|
||||
); err != nil {
|
||||
return fmt.Errorf("rendering volume fill: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
thickness := float32(2)
|
||||
bx := float32(p.volumeBarX)
|
||||
by := float32(p.volumeBarY)
|
||||
bw := float32(p.volumeBarW)
|
||||
bh := float32(p.volumeBarH)
|
||||
|
||||
outlineRects := [4][2]mgl32.Vec2{
|
||||
{{bx, by}, {bw, thickness}},
|
||||
{{bx, by + bh - thickness}, {bw, thickness}},
|
||||
{{bx, by}, {thickness, bh}},
|
||||
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
|
||||
}
|
||||
for _, r := range outlineRects {
|
||||
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
|
||||
return fmt.Errorf("rendering volume outline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderCursor draws the spinning triangle cursor next to the focused row.
|
||||
func (p *AudioOptions) renderCursor() error {
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Render() error {
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
return fmt.Errorf("render title: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||
return fmt.Errorf("render prompt: %w", err)
|
||||
}
|
||||
|
||||
// Volume row
|
||||
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
|
||||
return fmt.Errorf("render volume label: %w", err)
|
||||
}
|
||||
if err := p.renderVolumeBar(); err != nil {
|
||||
return fmt.Errorf("render volume bar: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil {
|
||||
return fmt.Errorf("render volume percent: %w", err)
|
||||
}
|
||||
|
||||
// Toggle
|
||||
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
|
||||
return fmt.Errorf("render audio toggle: %w", err)
|
||||
}
|
||||
|
||||
// Device list
|
||||
for i := range p.AudioDeviceTextures {
|
||||
item := &p.AudioDeviceTextures[i]
|
||||
color := colors.White
|
||||
if i == p.activeDeviceIndex {
|
||||
color = colors.Green
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
|
||||
return fmt.Errorf("render audio device %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Destroy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
||||
for i := range p.AudioDeviceTextures {
|
||||
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
type DownloadingAssets struct {
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Init(ctx *PageCtx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Render() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
type MessagePage struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (p *MessagePage) Init(ctx *PageCtx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Render() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
// emailRegex validates email format
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
type EmailAddressPage struct {
|
||||
Base
|
||||
|
||||
title string
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
currentEmailDisplayTextureID uint32
|
||||
currentEmailDisplayTextureWidth int32
|
||||
currentEmailDisplayTextureHeight int32
|
||||
|
||||
inputText string
|
||||
inputTextTextureID uint32
|
||||
inputTextTextureWidth int32
|
||||
inputTextTextureHeight int32
|
||||
|
||||
// Error message handling
|
||||
errorMessage string
|
||||
errorTextureID uint32
|
||||
errorTextureWidth int32
|
||||
errorTextureHeight int32
|
||||
|
||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
||||
isFirstTimeSetup bool
|
||||
|
||||
// Text input deduplication
|
||||
textInputSupported bool
|
||||
}
|
||||
|
||||
// isValidEmail checks if the given string is a valid email format
|
||||
func isValidEmail(email string) bool {
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *EmailAddressPage) Init(ctx *PageCtx) error {
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "Your Email Address"
|
||||
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Create title texture
|
||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
if titleTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
||||
}
|
||||
|
||||
p.titleTextureID = titleTextureID
|
||||
p.titleTextureWidth = titleWidth
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
// Load existing email address from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
emailAddress := config.EmailAddress
|
||||
|
||||
if emailAddress != "" {
|
||||
p.inputText = emailAddress
|
||||
p.isFirstTimeSetup = false
|
||||
|
||||
// Create current email display texture
|
||||
currentEmailText := fmt.Sprintf("Current Email: %s", emailAddress)
|
||||
currentEmailTextureID, currentEmailWidth, currentEmailHeight, err := fm.CreateStringTexture(applicationFont, currentEmailText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current email display texture: %w", err)
|
||||
}
|
||||
if currentEmailTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current email display")
|
||||
}
|
||||
|
||||
p.currentEmailDisplayTextureID = currentEmailTextureID
|
||||
p.currentEmailDisplayTextureWidth = currentEmailWidth
|
||||
p.currentEmailDisplayTextureHeight = currentEmailHeight
|
||||
} else {
|
||||
p.inputText = ""
|
||||
p.isFirstTimeSetup = true
|
||||
}
|
||||
|
||||
sdl.StartTextInput()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the EmailAddressPage.
|
||||
func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
|
||||
actualEvent := *event
|
||||
if !sdl.IsTextInputActive() {
|
||||
sdl.StartTextInput()
|
||||
}
|
||||
|
||||
switch e := actualEvent.(type) {
|
||||
case *sdl.TextInputEvent:
|
||||
if !p.textInputSupported {
|
||||
// First TextInputEvent: the keyboard fallback already
|
||||
// handled this character, so just record that text input
|
||||
// is supported and skip the duplicate append.
|
||||
p.textInputSupported = true
|
||||
} else {
|
||||
p.inputText += e.GetText()
|
||||
// Clear error when user starts typing
|
||||
p.errorMessage = ""
|
||||
}
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_ESCAPE:
|
||||
// Block escape during first-time setup
|
||||
if p.isFirstTimeSetup {
|
||||
return nil
|
||||
}
|
||||
// Allow escape back to settings if coming from settings
|
||||
sdl.StopTextInput()
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_RETURN:
|
||||
// Validate email - check empty first, then format
|
||||
if p.inputText == "" {
|
||||
p.errorMessage = "Email address cannot be empty"
|
||||
return nil
|
||||
}
|
||||
if !isValidEmail(p.inputText) {
|
||||
p.errorMessage = "Please enter a valid email address"
|
||||
return nil
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save email address to database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config.EmailAddress = p.inputText
|
||||
p.Context.DbPtr.Save(config)
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: navigate to License Key page
|
||||
p.Context.NavigateTo(LICENSE_KEY)
|
||||
} else {
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.inputText) > 0 {
|
||||
p.inputText = p.inputText[:len(p.inputText)-1]
|
||||
// Clear error when user edits input
|
||||
p.errorMessage = ""
|
||||
}
|
||||
default:
|
||||
// Keyboard fallback: if TextInputEvent is not supported,
|
||||
// convert printable keycodes to characters directly.
|
||||
// This handles kiosk systems without an input method framework.
|
||||
if !p.textInputSupported {
|
||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
||||
p.inputText += string(ch)
|
||||
p.errorMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||
func (p *EmailAddressPage) Update(dt float32) error {
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Update input text texture
|
||||
if len(p.inputText) > 0 {
|
||||
// Create the new inputText texture
|
||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.inputText,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
}
|
||||
|
||||
p.inputTextTextureID = inputTextTextureID
|
||||
p.inputTextTextureWidth = inputTextTextureWidth
|
||||
p.inputTextTextureHeight = inputTextTextureHeight
|
||||
}
|
||||
|
||||
// Update error message texture
|
||||
if p.errorMessage != "" {
|
||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.errorMessage,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
}
|
||||
|
||||
p.errorTextureID = errorTextureID
|
||||
p.errorTextureWidth = errorWidth
|
||||
p.errorTextureHeight = errorHeight
|
||||
} else {
|
||||
// Clear error texture if no error message
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render performs page drawing logic.
|
||||
func (p *EmailAddressPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
// 1. Calculate Scale
|
||||
titleFontSize := int32(64)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
currentEmailFontSize := int32(28)
|
||||
currentEmailScale := float32(currentEmailFontSize) / float32(baseFontSize)
|
||||
padding := int32(4)
|
||||
verticalMargin := int32(20)
|
||||
|
||||
// 2. Define dimensions
|
||||
targetFontSize := int32(36)
|
||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
||||
border := int32(3)
|
||||
boxH := targetFontSize + (padding * 2)
|
||||
boxW := int32(600) + (padding * 2)
|
||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
||||
borderBoxH := boxH + (border * 2)
|
||||
borderBoxW := boxW + (border * 2)
|
||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
||||
|
||||
var currentEmailDisplayH, currentEmailDisplayW float32
|
||||
var currentEmailDisplayDimensions mgl32.Vec2
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
currentEmailDisplayH = float32(p.currentEmailDisplayTextureHeight) * currentEmailScale
|
||||
currentEmailDisplayW = float32(p.currentEmailDisplayTextureWidth) * currentEmailScale
|
||||
currentEmailDisplayDimensions = mgl32.Vec2{currentEmailDisplayW, currentEmailDisplayH}
|
||||
}
|
||||
|
||||
// Error text dimensions
|
||||
errorFontSize := int32(20)
|
||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
||||
var errorTextH, errorTextW float32
|
||||
var errorTextDimensions mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
||||
}
|
||||
|
||||
// 3. Define positions
|
||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
inputTextY := boxY + padding
|
||||
inputTextX := boxX + padding
|
||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
||||
|
||||
var currentEmailDisplayPosition mgl32.Vec2
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
currentEmailDisplayY := borderBoxY - int32(currentEmailDisplayH) - verticalMargin
|
||||
currentEmailDisplayX := p.Base.ScreenCenterX - int32(currentEmailDisplayW/2)
|
||||
currentEmailDisplayPosition = mgl32.Vec2{float32(currentEmailDisplayX), float32(currentEmailDisplayY)}
|
||||
}
|
||||
|
||||
// Error text position (below current email display to avoid overlap)
|
||||
var errorTextPosition mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
var errorTextY int32
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
// Position below the current email display
|
||||
errorTextY = borderBoxY - int32(currentEmailDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
||||
} else {
|
||||
// No current email display, position below the input box
|
||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
||||
}
|
||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
||||
}
|
||||
|
||||
// 4. Define Colors
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
grey := sdl.Color{R: 120, G: 120, B: 120, A: 255}
|
||||
darkGrey := sdl.Color{R: 30, G: 30, B: 30, A: 255}
|
||||
|
||||
// 5. Get Shaders
|
||||
textShader, _ := sm.Get("text")
|
||||
solidShader, _ := sm.Get("solid_color")
|
||||
|
||||
// 6. Draw everything
|
||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, grey)
|
||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, darkGrey)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, white)
|
||||
|
||||
// Draw current email display if it exists
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.currentEmailDisplayTextureID, currentEmailDisplayPosition, currentEmailDisplayDimensions, white)
|
||||
}
|
||||
|
||||
if len(p.inputText) > 0 {
|
||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, white)
|
||||
}
|
||||
|
||||
// Draw error message if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *EmailAddressPage) Destroy() error {
|
||||
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
|
||||
// Delete current email display texture if it exists
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.currentEmailDisplayTextureID)
|
||||
p.currentEmailDisplayTextureID = 0
|
||||
}
|
||||
|
||||
// Delete input text texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
p.inputTextTextureID = 0
|
||||
}
|
||||
|
||||
// Delete error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"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/download"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "07112026-1710"
|
||||
|
||||
type Home struct {
|
||||
Base
|
||||
fm *fontManager.FontManager
|
||||
font *fontManager.FontEntry
|
||||
title StringItem
|
||||
version StringItem
|
||||
prompt StringItem
|
||||
note StringItem
|
||||
message StringItem
|
||||
downloadProgressChan chan download.DownloadStatus
|
||||
licenseInfoHeading StringItem
|
||||
emailDisplay StringItem
|
||||
licenseKeyDisplay StringItem
|
||||
hasLicenseInfo bool
|
||||
baseFontSize int
|
||||
isDownloadComplete bool
|
||||
downloadFailed bool
|
||||
downloadDone bool
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *Home) Init(ctx *PageCtx) error {
|
||||
title := "Radiant Wave"
|
||||
version := GitVersion
|
||||
prompt := "Press F2 to Start, or F3 for Settings"
|
||||
note := "You can always press F1 to return here"
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Get font
|
||||
p.fm = fontManager.Get()
|
||||
font, ok := p.fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch font")
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Create title texture
|
||||
var err error
|
||||
p.title, err = NewStringItem(title, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create title string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create version texture
|
||||
versionString := "Version: " + version
|
||||
p.version, err = NewStringItem(versionString, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create version string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create prompt texture
|
||||
p.prompt, err = NewStringItem(prompt, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create prompt string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create note texture
|
||||
p.note, err = NewStringItem(note, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create note string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create download channel and message
|
||||
p.downloadProgressChan = make(chan download.DownloadStatus)
|
||||
|
||||
// Initialize message
|
||||
p.message, err = NewStringItem("Status: Initializing...", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create initial message texture: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create license info textures if data exists
|
||||
if config.EmailAddress != "" || config.LicenseKey != "" {
|
||||
p.hasLicenseInfo = true
|
||||
|
||||
// Create heading
|
||||
p.licenseInfoHeading, err = NewStringItem("License Info", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create license info heading texture: %w", err)
|
||||
}
|
||||
|
||||
// Create email display if exists
|
||||
if config.EmailAddress != "" {
|
||||
emailText := fmt.Sprintf("Email: %s", config.EmailAddress)
|
||||
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create email display texture: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create license key display if exists
|
||||
if config.LicenseKey != "" {
|
||||
licenseKeyText := fmt.Sprintf("License Key: %s", config.LicenseKey)
|
||||
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create license key display texture: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
p.baseFontSize = baseFontSize
|
||||
|
||||
// Scale the note text
|
||||
fontSize := int32(32)
|
||||
scale := float32(fontSize) / float32(baseFontSize)
|
||||
p.note.W = int32(float32(p.note.W) * scale * 0.75)
|
||||
p.note.H = int32(float32(p.note.H) * scale * 0.75)
|
||||
|
||||
// Scale license info texts
|
||||
if p.hasLicenseInfo {
|
||||
licenseInfoScale := float32(28) / float32(baseFontSize)
|
||||
p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale)
|
||||
p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85)
|
||||
p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85)
|
||||
p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85)
|
||||
}
|
||||
}
|
||||
|
||||
// Scale message text
|
||||
messageScale := float32(28) / float32(baseFontSize)
|
||||
p.message.W = int32(float32(p.message.W) * messageScale)
|
||||
p.message.H = int32(float32(p.message.H) * messageScale)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the Home page.
|
||||
func (p *Home) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Home) Update(dt float32) error {
|
||||
var (
|
||||
progressUpdate download.DownloadStatus
|
||||
ok bool
|
||||
gotUpdate bool
|
||||
)
|
||||
|
||||
// Drain the channel, keeping only the newest update.
|
||||
for {
|
||||
select {
|
||||
case progressUpdate, ok = <-p.Context.MessageChannel:
|
||||
gotUpdate = true
|
||||
if !ok {
|
||||
if p.downloadFailed {
|
||||
p.setMessage("Download failed — restart to retry", colors.Red)
|
||||
} else {
|
||||
p.setMessage("Status: READY", colors.Green)
|
||||
p.isDownloadComplete = true
|
||||
if !p.downloadDone {
|
||||
p.downloadDone = true
|
||||
close(p.Context.DownloadDone)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
|
||||
drained:
|
||||
if !gotUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if progressUpdate.Err != nil {
|
||||
p.downloadFailed = true
|
||||
p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the display string based on what the download package tells us.
|
||||
var msg string
|
||||
if progressUpdate.Max <= 0 {
|
||||
// No total known yet — show the descriptive message from the package.
|
||||
// This covers: "Fetching file manifest", "Checking local files",
|
||||
// "Comparing checksums", "Building download list", etc.
|
||||
msg = fmt.Sprintf("Status: %s", progressUpdate.Msg)
|
||||
} else {
|
||||
// We have a known total — show percentage.
|
||||
pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100
|
||||
msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct)
|
||||
}
|
||||
|
||||
p.setMessage(msg, colors.White)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setMessage deletes the old message texture and builds a new one.
|
||||
func (p *Home) setMessage(text string, color sdl.Color) {
|
||||
if p.message.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.message.ID)
|
||||
p.message.ID = 0
|
||||
}
|
||||
|
||||
newMsg, err := NewStringItem(text, p.font, color)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
messageScale := float32(28) / float32(p.baseFontSize)
|
||||
newMsg.W = int32(float32(newMsg.W) * messageScale)
|
||||
newMsg.H = int32(float32(newMsg.H) * messageScale)
|
||||
p.message = newMsg
|
||||
}
|
||||
|
||||
func (p *Home) Render() error {
|
||||
baseFontSize := p.baseFontSize
|
||||
sm := shaderManager.Get()
|
||||
verticalMargin := int32(20)
|
||||
licenseInfoMargin := int32(10)
|
||||
|
||||
// 1) Calculate scales
|
||||
titleFontSize := int32(64)
|
||||
bodyFontSize := int32(32)
|
||||
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
bodyScale := float32(bodyFontSize) / float32(baseFontSize)
|
||||
|
||||
// 2) Calculate dimensions (scaled)
|
||||
titleW := float32(p.title.W) * titleScale
|
||||
titleH := float32(p.title.H) * titleScale
|
||||
titleDim := mgl32.Vec2{titleW, titleH}
|
||||
|
||||
versionW := float32(p.version.W) * bodyScale
|
||||
versionH := float32(p.version.H) * bodyScale
|
||||
versionDim := mgl32.Vec2{versionW, versionH}
|
||||
|
||||
promptW := float32(p.prompt.W) * bodyScale
|
||||
promptH := float32(p.prompt.H) * bodyScale
|
||||
promptDim := mgl32.Vec2{promptW, promptH}
|
||||
|
||||
// Note dimensions (already scaled in Init)
|
||||
noteW := float32(p.note.W)
|
||||
noteH := float32(p.note.H)
|
||||
|
||||
// Message dimensions (already scaled in Init/Update)
|
||||
messageW := float32(p.message.W)
|
||||
messageH := float32(p.message.H)
|
||||
|
||||
// License info dimensions (already scaled in Init)
|
||||
licenseHeadingW := float32(p.licenseInfoHeading.W)
|
||||
licenseHeadingH := float32(p.licenseInfoHeading.H)
|
||||
var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32
|
||||
if p.emailDisplay.ID != 0 {
|
||||
emailDisplayW = float32(p.emailDisplay.W)
|
||||
emailDisplayH = float32(p.emailDisplay.H)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseKeyDisplayW = float32(p.licenseKeyDisplay.W)
|
||||
licenseKeyDisplayH = float32(p.licenseKeyDisplay.H)
|
||||
}
|
||||
|
||||
// 3) Calculate total height of the vertical stack
|
||||
totalH := int32(titleH) + verticalMargin +
|
||||
int32(versionH) + verticalMargin +
|
||||
int32(promptH) + verticalMargin +
|
||||
int32(noteH)
|
||||
|
||||
// 4) Y-up layout: start from the TOP of the centered stack and step DOWN
|
||||
top := p.Base.ScreenCenterY + totalH/2
|
||||
|
||||
titleY := top - int32(titleH)
|
||||
versionY := titleY - verticalMargin - int32(versionH)
|
||||
p.prompt.Y = versionY - verticalMargin - int32(promptH)
|
||||
p.note.Y = p.prompt.Y - verticalMargin - int32(noteH)
|
||||
|
||||
// 5) Center X for each item
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
versionX := p.Base.ScreenCenterX - int32(versionW/2)
|
||||
p.prompt.X = p.Base.ScreenCenterX - int32(promptW/2)
|
||||
p.note.X = p.Base.ScreenCenterX - int32(noteW/2)
|
||||
|
||||
titlePos := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
versionPos := mgl32.Vec2{float32(versionX), float32(versionY)}
|
||||
|
||||
// 6) Position license info at bottom center
|
||||
if p.hasLicenseInfo {
|
||||
bottomMargin := int32(20)
|
||||
|
||||
// Calculate total height of license info block
|
||||
licenseInfoTotalH := int32(licenseHeadingH)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
licenseInfoTotalH += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseInfoTotalH += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Start from bottom and work up
|
||||
currentY := bottomMargin
|
||||
|
||||
// License key (bottom-most if it exists)
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
|
||||
p.licenseKeyDisplay.Y = currentY
|
||||
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Email
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
|
||||
p.emailDisplay.Y = currentY
|
||||
currentY += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Heading (top-most)
|
||||
p.licenseInfoHeading.X = p.Base.ScreenCenterX - int32(licenseHeadingW/2)
|
||||
p.licenseInfoHeading.Y = currentY
|
||||
}
|
||||
|
||||
// 7) Position message between license info and note/prompt
|
||||
if p.hasLicenseInfo {
|
||||
// Calculate the top of the license info block
|
||||
licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Center the message vertically between license info and note
|
||||
availableSpace := p.note.Y - licenseInfoTopY - verticalMargin
|
||||
p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2)
|
||||
} else {
|
||||
// If no license info, place message below note
|
||||
p.message.Y = p.note.Y - verticalMargin - int32(messageH)
|
||||
}
|
||||
p.message.X = p.Base.ScreenCenterX - int32(messageW/2)
|
||||
|
||||
// 8) Get shader and render
|
||||
textShader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to fetch text shader")
|
||||
}
|
||||
color := colors.White
|
||||
|
||||
p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color)
|
||||
p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color)
|
||||
p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color)
|
||||
p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color)
|
||||
|
||||
// Render message
|
||||
if p.message.ID != 0 {
|
||||
msgColor := colors.White
|
||||
if p.isDownloadComplete {
|
||||
msgColor = colors.Green
|
||||
}
|
||||
p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor)
|
||||
}
|
||||
|
||||
// Render license info if it exists
|
||||
if p.hasLicenseInfo {
|
||||
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *Home) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.version.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.note.ID)
|
||||
gl.DeleteTextures(1, &p.message.ID)
|
||||
|
||||
if p.hasLicenseInfo {
|
||||
gl.DeleteTextures(1, &p.licenseInfoHeading.ID)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.emailDisplay.ID)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.licenseKeyDisplay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
)
|
||||
|
||||
// Regression: DownloadDone was closed twice when checkState() re-navigated
|
||||
// to HOME on the DOWNLOADING_ASSETS -> READY transition. The new Home page
|
||||
// saw the already-closed MessageChannel and tried to close DownloadDone
|
||||
// again, causing panic: close of closed channel.
|
||||
//
|
||||
// Primary fix: checkState() now skips navigation when transitioning between
|
||||
// states that both map to the HOME page. The downloadDone flag on Home is
|
||||
// defense-in-depth.
|
||||
|
||||
// TestHomeDownloadDoneGuardPreventsDoubleClose verifies that the per-instance
|
||||
// downloadDone flag prevents closing DownloadDone more than once, even if a
|
||||
// new Home page somehow reaches the close path.
|
||||
func TestHomeDownloadDoneGuardPreventsDoubleClose(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
|
||||
// First Home closes DownloadDone.
|
||||
h1 := &Home{downloadDone: false}
|
||||
h1.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h1.downloadDone {
|
||||
h1.downloadDone = true
|
||||
close(h1.Context.DownloadDone)
|
||||
}
|
||||
|
||||
// Second Home with guard already set (simulates defense-in-depth).
|
||||
h2 := &Home{downloadDone: true}
|
||||
h2.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h2.downloadDone {
|
||||
h2.downloadDone = true
|
||||
close(h2.Context.DownloadDone) // would panic without guard
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadDoneGuardUnsetWouldPanic proves that without the
|
||||
// downloadDone guard, closing DownloadDone a second time panics.
|
||||
func TestHomeDownloadDoneGuardUnsetWouldPanic(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone) // pre-close
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("expected panic when closing already-closed channel")
|
||||
}
|
||||
}()
|
||||
|
||||
// New Home with downloadDone=false tries to close an already-closed channel.
|
||||
h := &Home{downloadDone: false}
|
||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone) // panics
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadFailedDoesNotClose verifies that when a download fails,
|
||||
// DownloadDone is NOT closed (the error path should not signal success).
|
||||
func TestHomeDownloadFailedDoesNotClose(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
|
||||
h := &Home{
|
||||
downloadFailed: true,
|
||||
downloadDone: false,
|
||||
}
|
||||
h.Context = &PageCtx{
|
||||
MessageChannel: make(chan download.DownloadStatus),
|
||||
DownloadDone: downloadDone,
|
||||
}
|
||||
|
||||
// Simulate the Update logic: on failure, downloadDone should not be set
|
||||
// and DownloadDone should not be closed.
|
||||
if h.downloadFailed {
|
||||
// Error path: do not close DownloadDone.
|
||||
} else if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-downloadDone:
|
||||
t.Error("DownloadDone should not be closed on download failure")
|
||||
default:
|
||||
// Correct: channel is still open.
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadDoneAlreadyClosed verifies that a Home page with
|
||||
// downloadDone=true never attempts to close the channel.
|
||||
func TestHomeDownloadDoneAlreadyClosed(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone)
|
||||
|
||||
h := &Home{downloadDone: true}
|
||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
|
||||
// Guard should prevent any close attempt.
|
||||
if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type LicenseKeyPage struct {
|
||||
Base
|
||||
|
||||
title string
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
currentLicenseDisplayTextureID uint32
|
||||
currentLicenseDisplayTextureWidth int32
|
||||
currentLicenseDisplayTextureHeight int32
|
||||
|
||||
inputText string
|
||||
inputTextTextureID uint32
|
||||
inputTextTextureWidth int32
|
||||
inputTextTextureHeight int32
|
||||
|
||||
// Error message handling
|
||||
errorMessage string
|
||||
errorTextureID uint32
|
||||
errorTextureWidth int32
|
||||
errorTextureHeight int32
|
||||
|
||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
||||
isFirstTimeSetup bool
|
||||
|
||||
// Text input deduplication
|
||||
textInputSupported bool
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "License Key"
|
||||
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Create title texture
|
||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
if titleTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
||||
}
|
||||
|
||||
p.titleTextureID = titleTextureID
|
||||
p.titleTextureWidth = titleWidth
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
// Load existing license key from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
licenseKey := config.LicenseKey
|
||||
|
||||
if licenseKey != "" {
|
||||
p.inputText = licenseKey
|
||||
p.isFirstTimeSetup = false
|
||||
|
||||
// Create current license key display texture
|
||||
currentLicenseText := fmt.Sprintf("Current License Key: %s", licenseKey)
|
||||
currentLicenseTextureID, currentLicenseWidth, currentLicenseHeight, err := fm.CreateStringTexture(applicationFont, currentLicenseText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current license display texture: %w", err)
|
||||
}
|
||||
if currentLicenseTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current license display")
|
||||
}
|
||||
|
||||
p.currentLicenseDisplayTextureID = currentLicenseTextureID
|
||||
p.currentLicenseDisplayTextureWidth = currentLicenseWidth
|
||||
p.currentLicenseDisplayTextureHeight = currentLicenseHeight
|
||||
} else {
|
||||
p.inputText = ""
|
||||
p.isFirstTimeSetup = true
|
||||
}
|
||||
|
||||
sdl.StartTextInput()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLicenseKey formats the input string with dashes every 4 characters
|
||||
// and limits to 16 alphanumeric characters (not counting dashes)
|
||||
func formatLicenseKey(input string) string {
|
||||
// Remove all dashes and non-alphanumeric characters
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, input)
|
||||
|
||||
// Limit to 16 characters
|
||||
if len(cleaned) > 16 {
|
||||
cleaned = cleaned[:16]
|
||||
}
|
||||
|
||||
// Insert dashes every 4 characters
|
||||
var result strings.Builder
|
||||
for i, char := range cleaned {
|
||||
if i > 0 && i%4 == 0 {
|
||||
result.WriteRune('-')
|
||||
}
|
||||
result.WriteRune(char)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the LicenseKeyPage.
|
||||
func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
||||
actualEvent := *event
|
||||
if !sdl.IsTextInputActive() {
|
||||
sdl.StartTextInput()
|
||||
}
|
||||
|
||||
switch e := actualEvent.(type) {
|
||||
case *sdl.TextInputEvent:
|
||||
if !p.textInputSupported {
|
||||
// First TextInputEvent: the keyboard fallback already
|
||||
// handled this character, so just record that text input
|
||||
// is supported and skip the duplicate append.
|
||||
p.textInputSupported = true
|
||||
} else {
|
||||
// Add the new character and reformat
|
||||
p.inputText += e.GetText()
|
||||
p.inputText = formatLicenseKey(p.inputText)
|
||||
// Clear error when user starts typing
|
||||
p.errorMessage = ""
|
||||
}
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_ESCAPE:
|
||||
// Block escape during first-time setup
|
||||
if p.isFirstTimeSetup {
|
||||
return nil
|
||||
}
|
||||
// Allow escape back to settings if coming from settings
|
||||
sdl.StopTextInput()
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_RETURN:
|
||||
// Validate license key - check empty first, then length, then characters
|
||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||
if cleaned == "" {
|
||||
p.errorMessage = "License key cannot be empty"
|
||||
return nil
|
||||
}
|
||||
if len(cleaned) != 16 {
|
||||
p.errorMessage = fmt.Sprintf("License key must be 16 characters (currently %d)", len(cleaned))
|
||||
return nil
|
||||
}
|
||||
// Check for invalid characters
|
||||
for _, r := range cleaned {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
p.errorMessage = "License key must contain only letters and numbers"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save license key to database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config.LicenseKey = p.inputText
|
||||
p.Context.DbPtr.Save(config)
|
||||
|
||||
// Write license key to plain text file for the updater
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
|
||||
if err := os.WriteFile(keyPath, []byte(p.inputText), 0644); err != nil {
|
||||
logger.Error("Failed to write license key file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: rewind to Welcome page
|
||||
p.Context.NavigateTo(HOME)
|
||||
} else {
|
||||
// Coming from settings: rewind back to settings
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.inputText) > 0 {
|
||||
// Remove last character and reformat
|
||||
// Strip all dashes first to get raw characters
|
||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||
if len(cleaned) > 0 {
|
||||
cleaned = cleaned[:len(cleaned)-1]
|
||||
}
|
||||
p.inputText = formatLicenseKey(cleaned)
|
||||
// Clear error when user edits input
|
||||
p.errorMessage = ""
|
||||
}
|
||||
default:
|
||||
// Keyboard fallback: if TextInputEvent is not supported,
|
||||
// convert printable keycodes to characters directly.
|
||||
// This handles kiosk systems without an input method framework.
|
||||
if !p.textInputSupported {
|
||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
||||
p.inputText += string(ch)
|
||||
p.inputText = formatLicenseKey(p.inputText)
|
||||
p.errorMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||
func (p *LicenseKeyPage) Update(dt float32) error {
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Update input text texture
|
||||
if len(p.inputText) > 0 {
|
||||
// Create the new inputText texture
|
||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.inputText,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
}
|
||||
|
||||
p.inputTextTextureID = inputTextTextureID
|
||||
p.inputTextTextureWidth = inputTextTextureWidth
|
||||
p.inputTextTextureHeight = inputTextTextureHeight
|
||||
}
|
||||
|
||||
// Update error message texture
|
||||
if p.errorMessage != "" {
|
||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.errorMessage,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
}
|
||||
|
||||
p.errorTextureID = errorTextureID
|
||||
p.errorTextureWidth = errorWidth
|
||||
p.errorTextureHeight = errorHeight
|
||||
} else {
|
||||
// Clear error texture if no error message
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render performs page drawing logic.
|
||||
func (p *LicenseKeyPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
// 1. Calculate Scale
|
||||
titleFontSize := int32(64)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
currentLicenseFontSize := int32(28)
|
||||
currentLicenseScale := float32(currentLicenseFontSize) / float32(baseFontSize)
|
||||
padding := int32(4)
|
||||
verticalMargin := int32(20)
|
||||
|
||||
// 2. Define dimensions
|
||||
targetFontSize := int32(36)
|
||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
||||
border := int32(3)
|
||||
|
||||
boxH := targetFontSize + (padding * 2)
|
||||
boxW := int32(600) + (padding * 2)
|
||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
||||
|
||||
borderBoxH := boxH + (border * 2)
|
||||
borderBoxW := boxW + (border * 2)
|
||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
||||
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
||||
|
||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
||||
|
||||
var currentLicenseDisplayH, currentLicenseDisplayW float32
|
||||
var currentLicenseDisplayDimensions mgl32.Vec2
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
currentLicenseDisplayH = float32(p.currentLicenseDisplayTextureHeight) * currentLicenseScale
|
||||
currentLicenseDisplayW = float32(p.currentLicenseDisplayTextureWidth) * currentLicenseScale
|
||||
currentLicenseDisplayDimensions = mgl32.Vec2{currentLicenseDisplayW, currentLicenseDisplayH}
|
||||
}
|
||||
|
||||
// Error text dimensions
|
||||
errorFontSize := int32(20)
|
||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
||||
var errorTextH, errorTextW float32
|
||||
var errorTextDimensions mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
||||
}
|
||||
|
||||
// 3. Define positions
|
||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
||||
|
||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
||||
|
||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
|
||||
inputTextY := boxY + padding
|
||||
inputTextX := boxX + padding
|
||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
||||
|
||||
var currentLicenseDisplayPosition mgl32.Vec2
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
currentLicenseDisplayY := borderBoxY - int32(currentLicenseDisplayH) - verticalMargin
|
||||
currentLicenseDisplayX := p.Base.ScreenCenterX - int32(currentLicenseDisplayW/2)
|
||||
currentLicenseDisplayPosition = mgl32.Vec2{float32(currentLicenseDisplayX), float32(currentLicenseDisplayY)}
|
||||
}
|
||||
|
||||
// Error text position (below current license display to avoid overlap)
|
||||
var errorTextPosition mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
var errorTextY int32
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
// Position below the current license display
|
||||
errorTextY = borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
||||
} else {
|
||||
// No current license display, position below the input box
|
||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
||||
}
|
||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
||||
}
|
||||
|
||||
// 4. Get Shaders
|
||||
textShader, _ := sm.Get("text")
|
||||
solidShader, _ := sm.Get("solid_color")
|
||||
|
||||
// 5. Draw everything
|
||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, colors.Gray)
|
||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, colors.DarkGray)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, colors.White)
|
||||
|
||||
// Draw current license display if it exists
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.currentLicenseDisplayTextureID, currentLicenseDisplayPosition, currentLicenseDisplayDimensions, colors.White)
|
||||
}
|
||||
|
||||
if len(p.inputText) > 0 {
|
||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, colors.White)
|
||||
}
|
||||
|
||||
// Draw error message if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *LicenseKeyPage) Destroy() error {
|
||||
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
|
||||
// Delete current license display texture if it exists
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.currentLicenseDisplayTextureID)
|
||||
p.currentLicenseDisplayTextureID = 0
|
||||
}
|
||||
|
||||
// Delete input text texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
p.inputTextTextureID = 0
|
||||
}
|
||||
|
||||
// Delete error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type LogViewerPage struct {
|
||||
Base
|
||||
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
lines []string
|
||||
lineTextureIDs []uint32
|
||||
lineTextureWidths []int32
|
||||
lineTextureHeights []int32
|
||||
lineColors []sdl.Color
|
||||
|
||||
hintTextureID uint32
|
||||
hintTextureWidth int32
|
||||
hintTextureHeight int32
|
||||
|
||||
scrollOffset int32
|
||||
maxScrollOffset int32
|
||||
lineHeight int32
|
||||
visibleHeight int32
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Init(ctx *PageCtx) error {
|
||||
var logs []db.Log
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
|
||||
logger.Error("error querying database for logs: %s", result.Error)
|
||||
return fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
|
||||
// // Format and word-wrap each log entry
|
||||
// for _, entry := range logs {
|
||||
// formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message)
|
||||
// wrapped := wrapText(formatted, 100)
|
||||
// p.lines = append(p.lines, strings.Split(wrapped, "\n")...)
|
||||
// }
|
||||
|
||||
levelColors := map[string]sdl.Color{
|
||||
"DEBUG": colors.SkyBlue,
|
||||
"INFO": colors.White,
|
||||
"WARNING": colors.Yellow,
|
||||
"ERROR": colors.RedOrange,
|
||||
}
|
||||
|
||||
fallbackColor := colors.White
|
||||
|
||||
p.lineColors = make([]sdl.Color, len(p.lines))
|
||||
|
||||
for _, entry := range logs {
|
||||
color, ok := levelColors[entry.Level]
|
||||
if !ok {
|
||||
color = fallbackColor
|
||||
}
|
||||
logTime := time.UnixMilli(entry.Timestamp)
|
||||
timeStr := logTime.Format("2006-01-02 15:04")
|
||||
line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
|
||||
p.lines = append(p.lines, line)
|
||||
p.lineColors = append(p.lineColors, color)
|
||||
}
|
||||
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Title texture
|
||||
titleID, titleW, titleH, err := fm.CreateStringTexture(applicationFont, "Application Logs", textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
p.titleTextureID = titleID
|
||||
p.titleTextureWidth = titleW
|
||||
p.titleTextureHeight = titleH
|
||||
|
||||
// Line textures
|
||||
p.lineTextureIDs = make([]uint32, len(p.lines))
|
||||
p.lineTextureWidths = make([]int32, len(p.lines))
|
||||
p.lineTextureHeights = make([]int32, len(p.lines))
|
||||
|
||||
for i, line := range p.lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create line texture: %w", err)
|
||||
}
|
||||
p.lineTextureIDs[i] = texID
|
||||
p.lineTextureWidths[i] = w
|
||||
p.lineTextureHeights[i] = h
|
||||
}
|
||||
|
||||
// Hint texture
|
||||
hintText := "Arrow Keys / PgUp/PgDn to scroll | F1|2|3 to Exit"
|
||||
hintID, hintW, hintH, err := fm.CreateStringTexture(applicationFont, hintText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hint texture: %w", err)
|
||||
}
|
||||
p.hintTextureID = hintID
|
||||
p.hintTextureWidth = hintW
|
||||
p.hintTextureHeight = hintH
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_UP:
|
||||
p.scrollOffset -= p.lineHeight
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
p.scrollOffset += p.lineHeight
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.scrollOffset -= p.visibleHeight
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
p.scrollOffset += p.visibleHeight
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
case sdl.K_HOME:
|
||||
p.scrollOffset = 0
|
||||
case sdl.K_END:
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
}
|
||||
case *sdl.MouseWheelEvent:
|
||||
p.scrollOffset -= e.Y * p.lineHeight * 3
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
textShader, _ := sm.Get("text")
|
||||
|
||||
titleFontSize := int32(48)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
contentFontSize := int32(24)
|
||||
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
||||
hintFontSize := int32(20)
|
||||
hintScale := float32(hintFontSize) / float32(baseFontSize)
|
||||
|
||||
margin := int32(40)
|
||||
lineSpacing := int32(4)
|
||||
p.lineHeight = contentFontSize + lineSpacing
|
||||
|
||||
contentLeftX := 25
|
||||
|
||||
// Title
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titleY := p.Base.ScreenHeight - margin - int32(titleH)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, colors.White)
|
||||
|
||||
// Hint at bottom
|
||||
hintH := float32(p.hintTextureHeight) * hintScale
|
||||
hintW := float32(p.hintTextureWidth) * hintScale
|
||||
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
||||
hintY := margin
|
||||
p.Base.RenderTexture(textShader, p.hintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, colors.Gray)
|
||||
|
||||
// Content area bounds
|
||||
contentTop := titleY - margin
|
||||
contentBottom := hintY + int32(hintH) + margin
|
||||
p.visibleHeight = contentTop - contentBottom
|
||||
|
||||
// Total content height
|
||||
totalContentHeight := int32(len(p.lines)) * p.lineHeight
|
||||
p.maxScrollOffset = totalContentHeight - p.visibleHeight
|
||||
if p.maxScrollOffset < 0 {
|
||||
p.maxScrollOffset = 0
|
||||
}
|
||||
|
||||
// Render visible lines
|
||||
currentY := contentTop - p.lineHeight + p.scrollOffset
|
||||
|
||||
for i, line := range p.lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
if p.lineTextureIDs[i] == 0 {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
||||
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
||||
|
||||
color := colors.White
|
||||
if i < len(p.lineColors) {
|
||||
color = p.lineColors[i]
|
||||
}
|
||||
|
||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, color)
|
||||
currentY -= p.lineHeight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
gl.DeleteTextures(1, &p.hintTextureID)
|
||||
|
||||
for _, texID := range p.lineTextureIDs {
|
||||
if texID != 0 {
|
||||
gl.DeleteTextures(1, &texID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package page provides the interface for pages
|
||||
package page
|
||||
|
||||
import (
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
// Page defines the contract for all application screens.
|
||||
type Page interface {
|
||||
Init(ctx *PageCtx) error
|
||||
HandleEvent(event *sdl.Event) error
|
||||
Update(dt float32) error
|
||||
Render() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
// Base is a helper struct that pages can embed to get common functionality.
|
||||
type Base struct {
|
||||
Context *PageCtx
|
||||
QuadVAO uint32
|
||||
QuadVBO uint32
|
||||
Projection mgl32.Mat4
|
||||
ScreenWidth int32
|
||||
ScreenHeight int32
|
||||
ScreenCenterX int32
|
||||
ScreenCenterY int32
|
||||
}
|
||||
|
||||
// Init stores the application context and sets up common resources.
|
||||
func (p *Base) Init(ctx *PageCtx) error {
|
||||
p.Context = ctx
|
||||
vertices := []float32{
|
||||
// pos(x,y) tex(u,v)
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
1.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
1.0, 1.0, 1.0, 1.0,
|
||||
1.0, 0.0, 1.0, 0.0,
|
||||
}
|
||||
|
||||
W := p.Context.W
|
||||
H := p.Context.H
|
||||
|
||||
p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1)
|
||||
p.ScreenHeight = H
|
||||
p.ScreenWidth = W
|
||||
p.ScreenCenterY = H / 2
|
||||
p.ScreenCenterX = W / 2
|
||||
|
||||
gl.GenVertexArrays(1, &p.QuadVAO)
|
||||
gl.GenBuffers(1, &p.QuadVBO)
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.QuadVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
||||
|
||||
stride := int32(4 * 4) // Each vertex has 4 floats (pos.x, pos.y, tex.u, tex.v)
|
||||
|
||||
// Attribute 0: Vertex Position (vec2)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, stride, 0)
|
||||
|
||||
// Attribute 1: Texture Coordinate (vec2)
|
||||
gl.EnableVertexAttribArray(1)
|
||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, stride, 2*4) // Offset by 2 floats
|
||||
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up the common OpenGL resources.
|
||||
func (p *Base) Destroy() error {
|
||||
gl.DeleteBuffers(1, &p.QuadVBO)
|
||||
gl.DeleteVertexArrays(1, &p.QuadVAO)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginTextureRendering sets up shared GL state for a batch of RenderTexture calls.
|
||||
// Call this once before a series of RenderTexture calls, then EndTextureRendering after.
|
||||
func (p *Base) BeginTextureRendering(shader *shaderManager.Shader) {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
}
|
||||
|
||||
// EndTextureRendering restores GL state after a batch of RenderTexture calls.
|
||||
func (p *Base) EndTextureRendering() {
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
}
|
||||
|
||||
// RenderTexture draws a given texture to the screen at a specified position and size.
|
||||
// For best performance, wrap multiple calls with BeginTextureRendering/EndTextureRendering.
|
||||
// This method also works standalone for one-off renders.
|
||||
func (p *Base) RenderTexture(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) error {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
|
||||
if shader.LocTextColor == -1 {
|
||||
return fmt.Errorf("could not find 'u_textColor' uniform in shader program %d", shader.ProgramID)
|
||||
}
|
||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTextureBatched is a lightweight version of RenderTexture for use inside
|
||||
// a BeginTextureRendering/EndTextureRendering block. It skips redundant state setup.
|
||||
func (p *Base) RenderTextureBatched(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) {
|
||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
}
|
||||
|
||||
// RenderSolidColorQuad draws a rectangle of a solid color to the screen.
|
||||
func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size mgl32.Vec2, color sdl.Color) error {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
if shader.LocMVPMatrix == -1 {
|
||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_mvpMatrix' uniform")
|
||||
}
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
if shader.LocColor == -1 {
|
||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_color' uniform")
|
||||
}
|
||||
gl.Uniform4f(shader.LocColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Provide default implementations for the Page interface.
|
||||
func (p *Base) HandleEvent(event sdl.Event) error { return nil }
|
||||
func (p *Base) Update(dt float32) error { return nil }
|
||||
func (p *Base) Render() error { return nil }
|
||||
|
||||
// --- Helper Functions ---
|
||||
// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state.
|
||||
// Returns 0 for non-printable keys.
|
||||
func keycodeToChar(sym sdl.Keycode, mod uint16) rune {
|
||||
shifted := mod&sdl.KMOD_SHIFT != 0
|
||||
|
||||
// Letters
|
||||
if sym >= sdl.K_a && sym <= sdl.K_z {
|
||||
ch := rune(sym)
|
||||
if shifted {
|
||||
ch -= 'a' - 'A'
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// Digits and their shifted symbols
|
||||
if sym >= sdl.K_0 && sym <= sdl.K_9 {
|
||||
if shifted {
|
||||
shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('}
|
||||
return shiftDigits[sym-sdl.K_0]
|
||||
}
|
||||
return rune(sym)
|
||||
}
|
||||
|
||||
// Common punctuation / symbols
|
||||
type charPair struct{ normal, shifted rune }
|
||||
punctuation := map[sdl.Keycode]charPair{
|
||||
sdl.K_SPACE: {' ', ' '},
|
||||
sdl.K_MINUS: {'-', '_'},
|
||||
sdl.K_EQUALS: {'=', '+'},
|
||||
sdl.K_LEFTBRACKET: {'[', '{'},
|
||||
sdl.K_RIGHTBRACKET: {']', '}'},
|
||||
sdl.K_BACKSLASH: {'\\', '|'},
|
||||
sdl.K_SEMICOLON: {';', ':'},
|
||||
sdl.K_QUOTE: {'\'', '"'},
|
||||
sdl.K_BACKQUOTE: {'`', '~'},
|
||||
sdl.K_COMMA: {',', '<'},
|
||||
sdl.K_PERIOD: {'.', '>'},
|
||||
sdl.K_SLASH: {'/', '?'},
|
||||
}
|
||||
if pair, ok := punctuation[sym]; ok {
|
||||
if shifted {
|
||||
return pair.shifted
|
||||
}
|
||||
return pair.normal
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// PageItem defines the basic features of any item on any page
|
||||
// This coupling makes creating, updating and managing pages easier
|
||||
type PageItem struct {
|
||||
ID uint32
|
||||
W int32
|
||||
H int32
|
||||
X int32 // Horizontal position
|
||||
Y int32 // Vertical position
|
||||
Color sdl.Color
|
||||
// index int
|
||||
}
|
||||
|
||||
// Scale updates the W and H of an item based on a scale factor (usually determined by base font size / desired font size)
|
||||
func (i *PageItem) Scale(scale float32) {
|
||||
i.W = int32(float32(i.W) * scale)
|
||||
i.H = int32(float32(i.H) * scale)
|
||||
}
|
||||
|
||||
// Dimensions returns an mgl32.Vec2 containing the W and H of an item, respectively
|
||||
func (i *PageItem) Dimensions(scale float32) mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(float32(i.W) * scale), float32(float32(i.H) * scale)}
|
||||
}
|
||||
|
||||
// RawDimensions returns a mgl32.Vec2 containing the W and H of an item, without scaling
|
||||
// This method will replace Dimensions in the future. Future PageItems should first call Scale(), then RawDimensions()
|
||||
func (i *PageItem) RawDimensions() mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(i.W), float32(i.H)}
|
||||
}
|
||||
|
||||
// Position returns an mgl32.Vec2 contianing the X and Y positions of an item, respectively
|
||||
func (i *PageItem) Position() mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(i.X), float32(i.Y)}
|
||||
}
|
||||
|
||||
// StringItem is an extended PageItem that contains a string content and string specific methods
|
||||
type StringItem struct {
|
||||
PageItem
|
||||
content string
|
||||
}
|
||||
|
||||
// NewStringItem creates a new StringItem with the given content, font, and color.
|
||||
// It initializes the texture ID, width, and height by creating a string texture using the font
|
||||
// and shader manager.
|
||||
func NewStringItem(content string, font *fontManager.FontEntry, color sdl.Color) (StringItem, error) {
|
||||
var err error
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return StringItem{}, fmt.Errorf("failed to fetch shader")
|
||||
}
|
||||
|
||||
id, width, height, err := fm.CreateStringTexture(font.Name, content, shader)
|
||||
if err != nil {
|
||||
return StringItem{}, err
|
||||
}
|
||||
|
||||
newStringItem := StringItem{
|
||||
PageItem: PageItem{
|
||||
ID: id,
|
||||
W: width,
|
||||
H: height,
|
||||
Color: color,
|
||||
},
|
||||
content: content,
|
||||
}
|
||||
|
||||
return newStringItem, err
|
||||
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
"rhemawavetech.com/rhemawave/internal/sync"
|
||||
)
|
||||
|
||||
// Scrollbar Visuals
|
||||
var (
|
||||
colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
||||
colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
||||
)
|
||||
|
||||
type FlatItem struct {
|
||||
Label StringItem
|
||||
ID string
|
||||
IsCategory bool
|
||||
IsSubCategory bool
|
||||
Depth int
|
||||
Expanded bool
|
||||
SearchText string // lowercase name + content, pre-built at load time
|
||||
}
|
||||
|
||||
type ProclamationOptions struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
// Browse Tree State
|
||||
allItems []*FlatItem
|
||||
activeMap map[string]bool
|
||||
highlightIndex int
|
||||
|
||||
// Browse Scrolling State
|
||||
scrollOffset int
|
||||
maxVisible int
|
||||
|
||||
// Scrollbar Dimens
|
||||
scrollTrackWidth int32
|
||||
scrollTrackX int32
|
||||
scrollTrackHeight int32
|
||||
scrollTrackY int32
|
||||
|
||||
indentSize float32
|
||||
verticalMargin int32
|
||||
|
||||
// Search State
|
||||
isSearchActive bool
|
||||
searchQuery string
|
||||
searchInput StringItem
|
||||
searchItems []*FlatItem // filtered view — pointers into allProclamations
|
||||
searchHighlight int
|
||||
searchScrollOff int
|
||||
|
||||
// Full flat proclamation pool (pointers shared with allItems leaves)
|
||||
allProclamations []*FlatItem
|
||||
|
||||
// Spinning triangle cursor — same shape/animation as AudioOptions —
|
||||
// that points at whichever row is currently highlighted.
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
triVisible bool
|
||||
spinElapsed float32
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
||||
p.verticalMargin = 25
|
||||
p.indentSize = 40.0
|
||||
p.activeMap = make(map[string]bool)
|
||||
p.allItems = []*FlatItem{}
|
||||
p.scrollOffset = 0
|
||||
p.isSearchActive = false
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
var err error
|
||||
|
||||
// Syncronize proclamations first
|
||||
var allProcs []db.Proclamation
|
||||
allProcs, err = sync.SyncProclamations(ctx.DbPtr)
|
||||
if err != nil {
|
||||
// sync failed, fallback to database
|
||||
ctx.DbPtr.Find(&allProcs)
|
||||
}
|
||||
|
||||
sync.SyncFrequencies(ctx.DbPtr)
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, _ := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
p.font = font
|
||||
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, _ = sm.Get("text")
|
||||
p.solidShader, _ = sm.Get("solid_color")
|
||||
|
||||
p.Base.Init(ctx)
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
|
||||
p.title.Scale(float32(64) / float32(config.StandardFontSize))
|
||||
|
||||
p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White)
|
||||
p.prompt.Scale(itemScale * 0.8)
|
||||
|
||||
// Calculation for visible area
|
||||
areaHeight := float32(p.ScreenHeight) - 250
|
||||
p.maxVisible = int(areaHeight / 47)
|
||||
|
||||
// Load current selections
|
||||
var selected []db.Proclamation
|
||||
ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
|
||||
for _, s := range selected {
|
||||
if s.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in selected list: %s", s.ID)
|
||||
}
|
||||
p.activeMap[s.ID] = true
|
||||
}
|
||||
|
||||
procContentMap := make(map[string]string, len(allProcs))
|
||||
for _, pr := range allProcs {
|
||||
if pr.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
|
||||
}
|
||||
procContentMap[pr.ID] = pr.Content
|
||||
}
|
||||
p.allProclamations = []*FlatItem{}
|
||||
|
||||
for _, pr := range allProcs {
|
||||
fi := p.createFlatItem(pr.Name, pr.ID, false, 0)
|
||||
fi.SearchText = strings.ToLower(pr.Name)
|
||||
p.allProclamations = append(p.allProclamations, fi)
|
||||
}
|
||||
|
||||
// Load Root Categories
|
||||
var parentCategories []db.ProclamationCategory
|
||||
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories)
|
||||
for _, c := range parentCategories {
|
||||
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
|
||||
}
|
||||
|
||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords.
|
||||
// Mirrors the spinning cursor used in AudioOptions.
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
|
||||
text := name
|
||||
if isCat && depth == 0 {
|
||||
text = "[+] " + name
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
lbl, _ := NewStringItem(text, p.font, colors.White)
|
||||
lbl.Scale(itemScale)
|
||||
return &FlatItem{
|
||||
Label: lbl,
|
||||
ID: id,
|
||||
IsCategory: isCat,
|
||||
IsSubCategory: isCat && depth > 0,
|
||||
Depth: depth,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) layout() {
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = 40
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.ScreenHeight - 60
|
||||
|
||||
startY := float32(p.ScreenHeight - 150)
|
||||
p.scrollTrackY = 130
|
||||
p.scrollTrackHeight = int32(startY) - 130 + 50
|
||||
|
||||
// Reserve room on the left of the list for the spinning cursor
|
||||
// (triangleSize wide, cursorGap away from the text it points at),
|
||||
// ahead of the original left margin used for list rows.
|
||||
cursorGap := int32(12)
|
||||
listBaseX := int32(60) + int32(triangleSize) + cursorGap
|
||||
|
||||
if p.isSearchActive {
|
||||
// Position search bar label
|
||||
p.searchInput.X = 60
|
||||
p.searchInput.Y = 100
|
||||
|
||||
// Hide all browse items
|
||||
for _, item := range p.allItems {
|
||||
item.Label.X = -5000
|
||||
}
|
||||
|
||||
// Position filtered search results
|
||||
for i, item := range p.searchItems {
|
||||
item.Label.X = -5000
|
||||
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
|
||||
visibleIdx := i - p.searchScrollOff
|
||||
item.Label.X = listBaseX
|
||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Point the cursor at the highlighted search result, if it's
|
||||
// currently scrolled into view.
|
||||
p.triVisible = false
|
||||
if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) &&
|
||||
p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
|
||||
target := p.searchItems[p.searchHighlight].Label
|
||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
||||
p.triVisible = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
for i, item := range p.allItems {
|
||||
item.Label.X = -5000
|
||||
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
|
||||
visibleIdx := i - p.scrollOffset
|
||||
item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
|
||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Point the cursor at the highlighted browse row, if it's currently
|
||||
// scrolled into view. Since target.X already includes the row's depth
|
||||
// indentation, the cursor shifts in and out with it automatically —
|
||||
// the gap between cursor and text stays constant regardless of depth.
|
||||
p.triVisible = false
|
||||
if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) &&
|
||||
p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
|
||||
target := p.allItems[p.highlightIndex].Label
|
||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
||||
p.triVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) toggleExpansion(index int) {
|
||||
item := p.allItems[index]
|
||||
if !item.IsCategory || item.IsSubCategory {
|
||||
return
|
||||
}
|
||||
|
||||
pureName := item.Label.content[4:]
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
if item.Expanded {
|
||||
p.collapse(index)
|
||||
item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White)
|
||||
} else {
|
||||
p.expand(index)
|
||||
item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White)
|
||||
}
|
||||
item.Label.Scale(itemScale)
|
||||
item.Expanded = !item.Expanded
|
||||
p.layout()
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) expand(index int) {
|
||||
parent := p.allItems[index]
|
||||
parentID, _ := strconv.Atoi(parent.ID)
|
||||
var newItems []*FlatItem
|
||||
|
||||
// Load subcategories
|
||||
var cats []db.ProclamationCategory
|
||||
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
|
||||
for _, c := range cats {
|
||||
fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1)
|
||||
fi.Expanded = true
|
||||
newItems = append(newItems, fi)
|
||||
}
|
||||
|
||||
// Load proclamations for this category
|
||||
var procs []db.Proclamation
|
||||
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
|
||||
|
||||
// Sort proclamations alphabetically by name
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name)
|
||||
})
|
||||
|
||||
for _, pr := range procs {
|
||||
var fi *FlatItem
|
||||
// Reuse existing proclamation from allProclamations if available
|
||||
for _, existing := range p.allProclamations {
|
||||
if existing.ID == pr.ID {
|
||||
fi = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if fi == nil {
|
||||
// Safety fallback
|
||||
fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1)
|
||||
fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content)
|
||||
p.allProclamations = append(p.allProclamations, fi)
|
||||
}
|
||||
fi.Depth = parent.Depth + 1
|
||||
newItems = append(newItems, fi)
|
||||
}
|
||||
|
||||
// Insert new items after parent
|
||||
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
|
||||
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
|
||||
|
||||
// Recurse into subcategories
|
||||
i := index + 1
|
||||
for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth {
|
||||
if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 {
|
||||
p.expand(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) collapse(index int) {
|
||||
depth := p.allItems[index].Depth
|
||||
i := index + 1
|
||||
for i < len(p.allItems) && p.allItems[i].Depth > depth {
|
||||
// Do NOT delete textures here — leaves are shared with allProclamations.
|
||||
// Only delete textures for category items that aren't in the proclamation pool.
|
||||
if p.allItems[i].IsCategory {
|
||||
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
|
||||
}
|
||||
i++
|
||||
}
|
||||
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
|
||||
}
|
||||
|
||||
// matchesWordPrefix returns true if any word in text starts with query (case-insensitive).
|
||||
func matchesWordPrefix(text, query string) bool {
|
||||
query = strings.ToLower(query)
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Split text into words (non-alphanumeric boundaries)
|
||||
words := strings.FieldsFunc(text, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
|
||||
for _, word := range words {
|
||||
if strings.HasPrefix(word, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) buildSearchList() {
|
||||
p.searchItems = nil
|
||||
if p.searchQuery == "" {
|
||||
return
|
||||
}
|
||||
for _, item := range p.allProclamations {
|
||||
if matchesWordPrefix(item.SearchText, p.searchQuery) {
|
||||
p.searchItems = append(p.searchItems, item)
|
||||
}
|
||||
}
|
||||
if p.searchHighlight >= len(p.searchItems) {
|
||||
p.searchHighlight = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) rebuildSearchLabel() {
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
p.searchInput.ID = 0
|
||||
}
|
||||
if p.searchQuery == "" {
|
||||
return
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow)
|
||||
p.searchInput.Scale(itemScale * 1.1)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) enterSearchMode(firstRune rune) {
|
||||
p.searchQuery = string(firstRune)
|
||||
p.isSearchActive = true
|
||||
p.searchScrollOff = 0
|
||||
p.searchHighlight = 0
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) exitSearchMode() {
|
||||
p.searchQuery = ""
|
||||
p.isSearchActive = false
|
||||
p.searchItems = nil
|
||||
p.searchHighlight = 0
|
||||
p.searchScrollOff = 0
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
p.searchInput.ID = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
sym := e.Keysym.Sym
|
||||
|
||||
// --- Backspace: always handled specially ---
|
||||
if sym == sdl.K_BACKSPACE {
|
||||
if p.isSearchActive {
|
||||
if len(p.searchQuery) > 0 {
|
||||
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
|
||||
}
|
||||
if p.searchQuery == "" {
|
||||
p.exitSearchMode()
|
||||
} else {
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
// Browse mode backspace — navigate back
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Escape ---
|
||||
if sym == sdl.K_ESCAPE {
|
||||
if p.isSearchActive {
|
||||
p.exitSearchMode()
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Search mode input handling ---
|
||||
if p.isSearchActive {
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.searchHighlight > 0 {
|
||||
p.searchHighlight--
|
||||
if p.searchHighlight < p.searchScrollOff {
|
||||
p.searchScrollOff--
|
||||
}
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.searchHighlight < len(p.searchItems)-1 {
|
||||
p.searchHighlight++
|
||||
if p.searchHighlight >= p.searchScrollOff+p.maxVisible {
|
||||
p.searchScrollOff++
|
||||
}
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.searchHighlight = p.searchScrollOff
|
||||
p.searchScrollOff -= p.maxVisible - 1
|
||||
if p.searchScrollOff < 0 {
|
||||
p.searchScrollOff = 0
|
||||
p.searchHighlight = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
bottom := p.searchScrollOff + p.maxVisible - 1
|
||||
if bottom >= len(p.searchItems) {
|
||||
bottom = len(p.searchItems) - 1
|
||||
}
|
||||
p.searchScrollOff = bottom
|
||||
p.searchHighlight = bottom
|
||||
case sdl.K_SPACE:
|
||||
if len(p.searchItems) > 0 {
|
||||
item := p.searchItems[p.searchHighlight]
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
||||
}
|
||||
p.acceptChanges()
|
||||
case sdl.K_RETURN:
|
||||
if len(p.searchItems) > 0 {
|
||||
item := p.searchItems[p.searchHighlight]
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
||||
}
|
||||
p.acceptChanges()
|
||||
default:
|
||||
// Append printable characters to search query
|
||||
r := rune(sym)
|
||||
if r >= 33 && r <= 126 {
|
||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
||||
r = unicode.ToUpper(r)
|
||||
}
|
||||
p.searchQuery += string(r)
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
}
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Browse mode ---
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
if p.highlightIndex < p.scrollOffset {
|
||||
p.scrollOffset--
|
||||
}
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.allItems)-1 {
|
||||
p.highlightIndex++
|
||||
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
|
||||
p.scrollOffset++
|
||||
}
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.highlightIndex = p.scrollOffset
|
||||
p.scrollOffset -= p.maxVisible - 1
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
p.highlightIndex = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
bottomIdx := p.scrollOffset + p.maxVisible - 1
|
||||
if bottomIdx >= len(p.allItems) {
|
||||
bottomIdx = len(p.allItems) - 1
|
||||
}
|
||||
p.scrollOffset = bottomIdx
|
||||
p.highlightIndex = bottomIdx
|
||||
case sdl.K_RETURN:
|
||||
item := p.allItems[p.highlightIndex]
|
||||
if !item.IsCategory { // If the item is NOT a Category
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
||||
p.acceptChanges()
|
||||
} else if !item.IsSubCategory {
|
||||
p.toggleExpansion(p.highlightIndex)
|
||||
}
|
||||
case sdl.K_SPACE:
|
||||
item := p.allItems[p.highlightIndex]
|
||||
if !item.IsCategory { // If the item is NOT a Category
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
||||
p.acceptChanges()
|
||||
} else if !item.IsSubCategory {
|
||||
p.toggleExpansion(p.highlightIndex)
|
||||
}
|
||||
default:
|
||||
// Any printable character enters search mode
|
||||
r := rune(sym)
|
||||
if r >= 33 && r <= 126 {
|
||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
||||
r = unicode.ToUpper(r)
|
||||
}
|
||||
p.enterSearchMode(r)
|
||||
}
|
||||
}
|
||||
|
||||
p.layout()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) acceptChanges() {
|
||||
p.Context.DbPtr.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false)
|
||||
var ids []string
|
||||
for id, active := range p.activeMap {
|
||||
if active {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
|
||||
logger.Debug("Saving selected state for %s\n", ids)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Update advances the spinning-cursor animation. The cursor's *position* is
|
||||
// recomputed in layout() whenever the highlighted row changes; this only
|
||||
// drives the spin itself, frame to frame.
|
||||
func (p *ProclamationOptions) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Render() error {
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
|
||||
if p.isSearchActive {
|
||||
// Search bar label
|
||||
if p.searchInput.ID != 0 {
|
||||
p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color)
|
||||
}
|
||||
|
||||
if len(p.searchItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
|
||||
item := p.searchItems[i]
|
||||
if p.activeMap[item.ID] {
|
||||
item.Label.Color = colors.Orange
|
||||
} else {
|
||||
item.Label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top, points at the highlighted result
|
||||
p.renderCursor()
|
||||
|
||||
p.renderSearchScrollbar()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
if len(p.allItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
|
||||
item := p.allItems[i]
|
||||
if item.IsCategory {
|
||||
item.Label.Color = colors.DodgerBlue
|
||||
} else if p.activeMap[item.ID] {
|
||||
item.Label.Color = colors.Orange
|
||||
} else {
|
||||
item.Label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top, points at the highlighted row
|
||||
p.renderCursor()
|
||||
|
||||
p.renderScrollbar()
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderCursor draws the spinning triangle cursor pointing at whichever row
|
||||
// is currently highlighted, in either browse or search mode. Identical
|
||||
// shape/animation to the cursor in AudioOptions.
|
||||
func (p *ProclamationOptions) renderCursor() error {
|
||||
if !p.triVisible {
|
||||
return nil
|
||||
}
|
||||
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) renderScrollbar() {
|
||||
total := len(p.allItems)
|
||||
if total <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
||||
colorScrollTrack)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
||||
scrollRatio := float32(p.scrollOffset) / float32(total)
|
||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
colorScrollThumb)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) renderSearchScrollbar() {
|
||||
total := len(p.searchItems)
|
||||
if total <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
||||
colorScrollTrack)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
||||
scrollRatio := float32(p.searchScrollOff) / float32(total)
|
||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
colorScrollThumb)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
}
|
||||
// Delete browse category textures
|
||||
for _, item := range p.allItems {
|
||||
if item.IsCategory {
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
}
|
||||
}
|
||||
// Delete proclamation textures via the pool (single source of truth)
|
||||
for _, item := range p.allProclamations {
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
)
|
||||
|
||||
type pageType int
|
||||
|
||||
const (
|
||||
HOME pageType = iota
|
||||
SCROLLER
|
||||
SETTINGS
|
||||
WIFI
|
||||
AUDIO
|
||||
PROCLAMATIONS
|
||||
LICENSE_KEY
|
||||
EMAIL_ADDRESS
|
||||
LOGS
|
||||
MESSAGE
|
||||
DOWNLOADING_ASSETS
|
||||
)
|
||||
|
||||
type PageCtx struct {
|
||||
DbPtr *gorm.DB
|
||||
W, H int32
|
||||
MessageChannel chan download.DownloadStatus
|
||||
DownloadDone chan struct{} // closed when downloads finish (success or failure)
|
||||
NavigateTo func(pageType)
|
||||
Msg string // Message to be displayed by MessagePage
|
||||
Inst string // Instructions to be displayed on MessagePage
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
ActivePage Page
|
||||
Context PageCtx
|
||||
}
|
||||
|
||||
func NewRouter(pgCtx PageCtx) *Router {
|
||||
r := &Router{
|
||||
Context: pgCtx,
|
||||
}
|
||||
r.Context.NavigateTo = r.NavigateTo
|
||||
r.NavigateTo(HOME)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Router) NavigateTo(p pageType) {
|
||||
if r.ActivePage != nil {
|
||||
r.ActivePage.Destroy()
|
||||
}
|
||||
|
||||
switch p {
|
||||
case HOME:
|
||||
r.ActivePage = &Home{}
|
||||
case SCROLLER:
|
||||
r.ActivePage = &ScrollerPage{}
|
||||
case SETTINGS:
|
||||
r.ActivePage = &Settings{}
|
||||
case WIFI:
|
||||
r.ActivePage = &WiFiSetupPage{}
|
||||
case AUDIO:
|
||||
r.ActivePage = &AudioOptions{}
|
||||
case PROCLAMATIONS:
|
||||
r.ActivePage = &ProclamationOptions{}
|
||||
case LICENSE_KEY:
|
||||
r.ActivePage = &LicenseKeyPage{}
|
||||
case EMAIL_ADDRESS:
|
||||
r.ActivePage = &EmailAddressPage{}
|
||||
case LOGS:
|
||||
r.ActivePage = &LogViewerPage{}
|
||||
case MESSAGE:
|
||||
r.ActivePage = &MessagePage{}
|
||||
case DOWNLOADING_ASSETS:
|
||||
r.ActivePage = &DownloadingAssets{}
|
||||
}
|
||||
|
||||
r.ActivePage.Init(&r.Context)
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"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/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
||||
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
|
||||
proclamationPlayCount int = 20
|
||||
// defaultProclamationPlayCount int = 1
|
||||
)
|
||||
|
||||
type ScrollerPage struct {
|
||||
Base
|
||||
|
||||
streams []proclamationStream
|
||||
streamIndex int
|
||||
|
||||
// currentAudioFreq is the solfeggio Hz value of whatever track is
|
||||
// currently playing. 0 means "no specific frequency" (either the
|
||||
// legacy Selected-AudioFile track, or nothing yet).
|
||||
currentAudioFreq int
|
||||
|
||||
IsDownloadingAudio bool
|
||||
|
||||
lines *LineDeque
|
||||
lineHeight int32
|
||||
shader *shaderManager.Shader
|
||||
activePattern linePattern.LinePattern
|
||||
font *fontManager.FontEntry
|
||||
spaceWidth int32
|
||||
lineCtx *linePattern.LineRenderContext
|
||||
colorPattern *colorPattern.ColorPattern
|
||||
scrollOffset float32
|
||||
scrollY float32
|
||||
fontScale float32
|
||||
|
||||
// Reuse one GL texture per unique word.
|
||||
wordTextureCache map[string]StringItem
|
||||
|
||||
increment float32
|
||||
velocity float32
|
||||
|
||||
// Time-based velocity control
|
||||
velocityFloor float32
|
||||
velocityCeiling float32
|
||||
velocityStep float32
|
||||
velocityDelay float32
|
||||
velocityDirection int
|
||||
velocityTimer float32
|
||||
velocityState int
|
||||
autoVelocity bool
|
||||
hold float32
|
||||
|
||||
// Color palette settings
|
||||
useColorPalettes bool
|
||||
}
|
||||
|
||||
type ScrollerProclamation struct {
|
||||
db.Proclamation
|
||||
colorPattern.ColorPattern
|
||||
ColorPalette []sdl.Color
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
words []StringItem
|
||||
width int32
|
||||
color sdl.Color // Color for the entire line
|
||||
}
|
||||
|
||||
// proclamationStream tracks one proclamation and how many full loops it should play.
|
||||
type proclamationStream struct {
|
||||
name string
|
||||
words []string
|
||||
repeat int
|
||||
completed int
|
||||
wordIndex int
|
||||
palette []sdl.Color // Color palette for this proclamation
|
||||
colorIndex int // Track which color to use next
|
||||
|
||||
// frequencyValue is the solfeggio Hz value that should be audible while
|
||||
// this stream plays. 0 means "no specific frequency" (the default
|
||||
// proclamation) — entering this stream never changes the audio.
|
||||
frequencyValue int
|
||||
}
|
||||
|
||||
// LineDeque is a double-ended queue for Line values backed by a circular buffer.
|
||||
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
|
||||
type LineDeque struct {
|
||||
buf []Line
|
||||
head, tail int
|
||||
count int
|
||||
}
|
||||
|
||||
func NewLineDeque(cap int) *LineDeque {
|
||||
if cap < 4 {
|
||||
cap = 4
|
||||
}
|
||||
return &LineDeque{buf: make([]Line, cap)}
|
||||
}
|
||||
|
||||
func (d *LineDeque) Len() int { return d.count }
|
||||
|
||||
func (d *LineDeque) grow() {
|
||||
newCap := len(d.buf) * 2
|
||||
newBuf := make([]Line, newCap)
|
||||
for i := 0; i < d.count; i++ {
|
||||
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
|
||||
}
|
||||
d.buf = newBuf
|
||||
d.head = 0
|
||||
d.tail = d.count
|
||||
}
|
||||
|
||||
func (d *LineDeque) PushBack(l Line) {
|
||||
if d.count == len(d.buf) {
|
||||
d.grow()
|
||||
}
|
||||
d.buf[d.tail] = l
|
||||
d.tail = (d.tail + 1) % len(d.buf)
|
||||
d.count++
|
||||
}
|
||||
|
||||
func (d *LineDeque) PushFront(l Line) {
|
||||
if d.count == len(d.buf) {
|
||||
d.grow()
|
||||
}
|
||||
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
|
||||
d.buf[d.head] = l
|
||||
d.count++
|
||||
}
|
||||
|
||||
func (d *LineDeque) PopFront() Line {
|
||||
l := d.buf[d.head]
|
||||
d.buf[d.head] = Line{} // clear for GC
|
||||
d.head = (d.head + 1) % len(d.buf)
|
||||
d.count--
|
||||
return l
|
||||
}
|
||||
|
||||
func (d *LineDeque) PopBack() Line {
|
||||
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
|
||||
l := d.buf[d.tail]
|
||||
d.buf[d.tail] = Line{} // clear for GC
|
||||
d.count--
|
||||
return l
|
||||
}
|
||||
|
||||
func (d *LineDeque) Front() Line { return d.buf[d.head] }
|
||||
|
||||
func (d *LineDeque) Back() Line {
|
||||
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
|
||||
}
|
||||
|
||||
func (d *LineDeque) Get(i int) *Line {
|
||||
return &d.buf[(d.head+i)%len(d.buf)]
|
||||
}
|
||||
|
||||
func (d *LineDeque) Set(i int, l Line) {
|
||||
d.buf[(d.head+i)%len(d.buf)] = l
|
||||
}
|
||||
|
||||
func (d *LineDeque) Clear() {
|
||||
for i := 0; i < d.count; i++ {
|
||||
d.buf[(d.head+i)%len(d.buf)] = Line{}
|
||||
}
|
||||
d.head, d.tail, d.count = 0, 0, 0
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
p.increment = 100.0
|
||||
p.velocity = 800.0
|
||||
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
||||
p.useColorPalettes = true // Default to enabled
|
||||
|
||||
sm := shaderManager.Get()
|
||||
fm := fontManager.Get()
|
||||
|
||||
// Get font
|
||||
font, ok := fm.GetFont("Scrambled")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch font 'Scrambled'")
|
||||
}
|
||||
p.font = font
|
||||
logger.Debug("Font name: %s", font.Name)
|
||||
|
||||
p.Base.Init(ctx)
|
||||
|
||||
// Get text shader
|
||||
textShader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("text shader not found")
|
||||
}
|
||||
p.shader = textShader
|
||||
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
|
||||
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
|
||||
|
||||
p.wordTextureCache = make(map[string]StringItem, 256)
|
||||
|
||||
// Load proclamation streams from database
|
||||
if err := p.loadProclamations(); err != nil {
|
||||
logger.Error("Error loading proclamations: %v", err)
|
||||
p.Context.NavigateTo(PROCLAMATIONS)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get line pattern from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
linePatternName := config.LinePattern
|
||||
|
||||
p.activePattern, err = linePattern.Get(linePatternName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
||||
}
|
||||
|
||||
// Initialize color pattern
|
||||
baseColors := []sdl.Color{
|
||||
colors.Tan,
|
||||
colors.PeachPuff,
|
||||
colors.LightCyan,
|
||||
colors.LightSeaGreen,
|
||||
colors.MediumTurquoise,
|
||||
colors.DarkCyan,
|
||||
colors.MediumPurple,
|
||||
}
|
||||
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
||||
|
||||
// Font setup
|
||||
p.fontScale = float32(32) / float32(config.StandardFontSize)
|
||||
nLines := int(float32(p.ScreenHeight) / 32.0)
|
||||
p.lines = NewLineDeque(nLines * 2)
|
||||
|
||||
// Calculate space width using the scale ratio.
|
||||
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get space glyph")
|
||||
}
|
||||
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
||||
|
||||
// Get line height using the same scale ratio.
|
||||
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sample texture: %w", err)
|
||||
}
|
||||
p.lineHeight = int32(float32(H) * p.fontScale)
|
||||
gl.DeleteTextures(1, &tmpStrID)
|
||||
|
||||
p.lineCtx = &linePattern.LineRenderContext{
|
||||
ScreenWidth: p.ScreenWidth,
|
||||
Font: font,
|
||||
FontScale: p.fontScale,
|
||||
SpaceWidth: p.spaceWidth,
|
||||
}
|
||||
|
||||
// Create initial lines
|
||||
for i := range nLines {
|
||||
if err := p.createLine(i, true); err != nil {
|
||||
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start audio for the first proclamation in the stream. Falls back to
|
||||
// whatever track is marked Selected in the audio_files table if the
|
||||
// first stream has no frequency (e.g. only the default proclamation is
|
||||
// queued) or its matching file doesn't exist on disk yet.
|
||||
started := false
|
||||
if len(p.streams) > 0 {
|
||||
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
||||
}
|
||||
|
||||
if !started {
|
||||
var selectedAudio db.AudioFile
|
||||
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
|
||||
selectedAudio = db.AudioFile{
|
||||
Name: "OriginalAudio",
|
||||
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
|
||||
Selected: true,
|
||||
Order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Playing %s", selectedAudio.Path)
|
||||
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
currentDevice := mixer.CurrentDevice()
|
||||
if currentDevice == "" {
|
||||
currentDevice = "default"
|
||||
}
|
||||
_ = currentDevice
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadProclamations loads selected proclamations as stream units (not one giant word list).
|
||||
func (p *ScrollerPage) loadProclamations() error {
|
||||
p.streams = p.streams[:0]
|
||||
|
||||
// Load all solfeggio frequencies
|
||||
var frequencies []db.SolfeggioFrequency
|
||||
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
|
||||
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
|
||||
}
|
||||
|
||||
// Create a map of frequency ID to its value
|
||||
frequencyMap := make(map[int]int)
|
||||
for _, freq := range frequencies {
|
||||
frequencyMap[freq.ID] = freq.Value
|
||||
}
|
||||
|
||||
// Map frequency values to their color palettes
|
||||
frequencyPalettes := map[int][]sdl.Color{
|
||||
396: colors.Freq_396_Colors[:],
|
||||
417: colors.Freq_417_Colors[:],
|
||||
528: colors.Freq_528_Colors[:], // Default proclamation
|
||||
639: colors.Freq_639_Colors[:],
|
||||
741: colors.Freq_741_Colors[:],
|
||||
852: colors.Freq_852_Colors[:],
|
||||
963: colors.Freq_963_Colors[:],
|
||||
}
|
||||
|
||||
// Load selected proclamations
|
||||
var selected []db.Proclamation
|
||||
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load default proclamation
|
||||
var defaultProclamation db.Proclamation
|
||||
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
defaultWords := strings.Fields(defaultProclamation.Content)
|
||||
|
||||
// Build interleaved stream:
|
||||
// each selected proclamation repeats N times, then default repeats once.
|
||||
for _, proclamation := range selected {
|
||||
// Skip default proclamation if it's in the selected list
|
||||
if proclamation.ID == defaultProclamationID {
|
||||
continue
|
||||
}
|
||||
|
||||
words := strings.Fields(proclamation.Content)
|
||||
if len(words) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the frequency value for this proclamation
|
||||
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
|
||||
if !ok {
|
||||
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
|
||||
freqValue = 528 // Default to 528 if frequency not found
|
||||
}
|
||||
|
||||
// Get the palette for this frequency
|
||||
palette, ok := frequencyPalettes[freqValue]
|
||||
if !ok {
|
||||
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
|
||||
palette = colors.Freq_528_Colors[:] // Fallback to default palette
|
||||
}
|
||||
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: proclamation.Name,
|
||||
words: words,
|
||||
repeat: proclamationPlayCount,
|
||||
palette: palette,
|
||||
colorIndex: 0,
|
||||
frequencyValue: freqValue,
|
||||
})
|
||||
|
||||
if len(defaultWords) > 0 {
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: defaultProclamation.Name,
|
||||
words: defaultWords,
|
||||
repeat: 1,
|
||||
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
|
||||
colorIndex: 0,
|
||||
frequencyValue: 0, // default proclamation never changes audio
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no non-default selected proclamations, at least play default.
|
||||
if len(p.streams) == 0 && len(defaultWords) > 0 {
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: defaultProclamation.Name,
|
||||
words: defaultWords,
|
||||
repeat: 1,
|
||||
palette: colors.Freq_528_Colors[:],
|
||||
colorIndex: 0,
|
||||
frequencyValue: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(p.streams) == 0 {
|
||||
return fmt.Errorf("failed to load proclamations: no proclamation words available")
|
||||
}
|
||||
|
||||
p.streamIndex = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// audioDir returns the path to the audio files directory (~/.radiantwave/audio).
|
||||
func audioDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return filepath.Join(home, ".radiantwave", "audio")
|
||||
}
|
||||
|
||||
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
|
||||
// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav".
|
||||
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
|
||||
return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq))
|
||||
}
|
||||
|
||||
// switchAudioForFrequency swaps the currently playing track to match freq.
|
||||
//
|
||||
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
|
||||
// always a no-op — whatever is playing keeps playing.
|
||||
// - If freq already matches what's playing, nothing happens.
|
||||
// - If the matching file doesn't exist on disk yet, the current track
|
||||
// keeps playing untouched (so missing solfeggio audio never causes
|
||||
// silence — and starts working automatically once the file is added).
|
||||
// - Otherwise the current track is stopped immediately and freq's track
|
||||
// starts right away (hard cut, no fade).
|
||||
//
|
||||
// Returns true if freq's track is now playing (either just switched, or
|
||||
// already was).
|
||||
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
||||
if freq == 0 {
|
||||
return false
|
||||
}
|
||||
if freq == p.currentAudioFreq {
|
||||
return true
|
||||
}
|
||||
|
||||
path := p.audioPathForFrequency(freq)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
|
||||
return false
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mixer.Stop(3000, config)
|
||||
if err := mixer.Play(path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
return false
|
||||
}
|
||||
p.currentAudioFreq = freq
|
||||
return true
|
||||
}
|
||||
|
||||
// nextLineWords gets words for the current line and advances proclamation stream when needed.
|
||||
func (p *ScrollerPage) nextLineWords() []string {
|
||||
if len(p.streams) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := &p.streams[p.streamIndex]
|
||||
if len(s.words) == 0 {
|
||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
||||
return p.nextLineWords()
|
||||
}
|
||||
|
||||
prevIdx := s.wordIndex
|
||||
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
|
||||
|
||||
// If word index wraps, one full proclamation pass completed.
|
||||
if s.wordIndex < prevIdx {
|
||||
s.completed++
|
||||
targetRepeats := s.repeat
|
||||
if targetRepeats <= 0 {
|
||||
targetRepeats = 1
|
||||
}
|
||||
if s.completed >= targetRepeats {
|
||||
s.completed = 0
|
||||
s.wordIndex = 0
|
||||
s.colorIndex = 0 // Reset color index when proclamation restarts
|
||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
||||
next := &p.streams[p.streamIndex]
|
||||
p.switchAudioForFrequency(next.frequencyValue)
|
||||
}
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// getCachedWord returns a reusable textured StringItem for a word.
|
||||
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
|
||||
if cached, ok := p.wordTextureCache[word]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||
if err != nil {
|
||||
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
|
||||
}
|
||||
base.Scale(p.fontScale)
|
||||
|
||||
p.wordTextureCache[word] = base
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
|
||||
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
|
||||
line := Line{words: []StringItem{}}
|
||||
|
||||
// Get the current proclamation's color palette
|
||||
var lineColor sdl.Color
|
||||
if p.useColorPalettes && len(p.streams) > 0 {
|
||||
currentStream := &p.streams[p.streamIndex]
|
||||
if len(currentStream.palette) > 0 {
|
||||
// Use the current color from the palette
|
||||
lineColor = currentStream.palette[currentStream.colorIndex]
|
||||
// Increment color index for the next line
|
||||
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
|
||||
} else {
|
||||
// Fallback to default color if no palette is found
|
||||
lineColor = p.colorPattern.Color()
|
||||
}
|
||||
} else {
|
||||
// Use the default color pattern if useColorPalettes is false
|
||||
lineColor = p.colorPattern.Color()
|
||||
}
|
||||
|
||||
for _, word := range words {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cached, err := p.getCachedWord(word)
|
||||
if err != nil {
|
||||
return Line{}, err
|
||||
}
|
||||
|
||||
item := cached
|
||||
item.Color = lineColor // Assign the line color to all words in the line
|
||||
|
||||
if len(line.words) > 0 {
|
||||
line.width += p.spaceWidth
|
||||
}
|
||||
line.words = append(line.words, item)
|
||||
line.width += item.W
|
||||
}
|
||||
|
||||
line.color = lineColor // Store the line color
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// positionLine sets X/Y for all words in a line.
|
||||
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
|
||||
startX := p.ScreenCenterX - (line.width / 2)
|
||||
for j := range line.words {
|
||||
if j == 0 {
|
||||
line.words[j].X = startX
|
||||
} else {
|
||||
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
|
||||
}
|
||||
line.words[j].Y = baseY
|
||||
}
|
||||
}
|
||||
|
||||
// createLine is a helper to generate a new line at a specific index.
|
||||
// isInitial determines if this is during initialization (positions from top)
|
||||
// or dynamic creation (positions relative to existing lines).
|
||||
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
|
||||
words := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(words)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if isInitial {
|
||||
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
||||
} else {
|
||||
baseY = 0
|
||||
}
|
||||
p.positionLine(&line, baseY)
|
||||
|
||||
if isInitial {
|
||||
p.lines.PushBack(line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
||||
// switch e := (*event).(type) {
|
||||
// case *sdl.KeyboardEvent:
|
||||
// if e.Type != sdl.KEYDOWN {
|
||||
// return nil
|
||||
// }
|
||||
// switch e.Keysym.Sym {
|
||||
// case sdl.K_UP:
|
||||
// if !p.autoVelocity {
|
||||
// p.velocity += p.increment
|
||||
// }
|
||||
// case sdl.K_DOWN:
|
||||
// if !p.autoVelocity {
|
||||
// p.velocity -= p.increment
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Update(dt float32) error {
|
||||
p.updateAutoVelocity(dt)
|
||||
|
||||
p.scrollOffset += dt * p.velocity
|
||||
move := int32(p.scrollOffset)
|
||||
if move == 0 {
|
||||
return nil
|
||||
}
|
||||
p.scrollOffset -= float32(move)
|
||||
|
||||
// Move all lines vertically
|
||||
for i := 0; i < p.lines.Len(); i++ {
|
||||
line := p.lines.Get(i)
|
||||
for j := range line.words {
|
||||
line.words[j].Y += move
|
||||
}
|
||||
}
|
||||
|
||||
// Handle line replacement based on scroll direction
|
||||
if p.velocity > 0 {
|
||||
// Moving up: replace lines that go off top
|
||||
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
|
||||
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
|
||||
|
||||
if err := p.addLineAtBottom(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if p.velocity < 0 {
|
||||
// Moving down: replace lines that go off bottom
|
||||
for p.lines.Len() > 0 {
|
||||
back := p.lines.Back()
|
||||
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
|
||||
|
||||
if err := p.addLineAtTop(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLineAtBottom creates and adds a new line at the bottom of the display.
|
||||
func (p *ScrollerPage) addLineAtBottom() error {
|
||||
newWords := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(newWords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
|
||||
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
|
||||
} else {
|
||||
baseY = -int32(p.lineHeight)
|
||||
}
|
||||
|
||||
p.positionLine(&line, baseY)
|
||||
p.lines.PushBack(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLineAtTop creates and adds a new line at the top of the display.
|
||||
func (p *ScrollerPage) addLineAtTop() error {
|
||||
newWords := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(newWords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
|
||||
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
|
||||
} else {
|
||||
baseY = p.ScreenHeight
|
||||
}
|
||||
|
||||
p.positionLine(&line, baseY)
|
||||
p.lines.PushFront(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Render() error {
|
||||
p.BeginTextureRendering(p.shader)
|
||||
for i := 0; i < p.lines.Len(); i++ {
|
||||
line := p.lines.Get(i)
|
||||
for _, word := range line.words {
|
||||
if word.ID == 0 {
|
||||
continue
|
||||
}
|
||||
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
|
||||
}
|
||||
}
|
||||
p.EndTextureRendering()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Destroy() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mixer.Stop(3000, config)
|
||||
|
||||
// Delete each cached word texture exactly once.
|
||||
for k, item := range p.wordTextureCache {
|
||||
if item.ID != 0 {
|
||||
id := item.ID
|
||||
gl.DeleteTextures(1, &id)
|
||||
}
|
||||
delete(p.wordTextureCache, k)
|
||||
}
|
||||
|
||||
gl.Flush()
|
||||
|
||||
// Drop references for GC safely
|
||||
p.lines = nil
|
||||
p.streams = nil
|
||||
p.lineCtx = nil
|
||||
p.font = nil
|
||||
p.shader = nil
|
||||
p.colorPattern = nil
|
||||
p.wordTextureCache = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
|
||||
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
|
||||
p.autoVelocity = enabled
|
||||
p.velocityFloor = -float32(math.Abs(float64(floor)))
|
||||
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
|
||||
p.velocityStep = float32(math.Abs(float64(step)))
|
||||
p.velocityDelay = delay
|
||||
p.hold = hold
|
||||
|
||||
// Initialize state
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
p.velocityTimer = 0.0
|
||||
p.velocity = p.velocityFloor
|
||||
}
|
||||
|
||||
// SetUseColorPalettes enables or disables the use of color palettes for lines.
|
||||
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
|
||||
p.useColorPalettes = enabled
|
||||
}
|
||||
|
||||
// updateAutoVelocity handles automatic velocity changes.
|
||||
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
|
||||
if !p.autoVelocity {
|
||||
return
|
||||
}
|
||||
|
||||
switch p.velocityState {
|
||||
case 0: // Changing velocity
|
||||
if p.velocityDirection == 1 {
|
||||
p.velocity += p.velocityStep * dt
|
||||
if p.velocity >= p.velocityCeiling {
|
||||
p.velocity = p.velocityCeiling
|
||||
p.velocityState = 1
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
} else {
|
||||
p.velocity -= p.velocityStep * dt
|
||||
if p.velocity <= p.velocityFloor {
|
||||
p.velocity = p.velocityFloor
|
||||
p.velocityState = 2
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
case 1: // Waiting at ceiling
|
||||
p.velocityTimer += dt
|
||||
if p.velocityTimer >= p.velocityDelay {
|
||||
p.velocityDirection = -1
|
||||
p.velocityState = 0
|
||||
}
|
||||
|
||||
case 2: // Waiting at floor
|
||||
p.velocityTimer += dt
|
||||
if p.velocityTimer >= p.velocityDelay {
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
options []Option
|
||||
verticalMargin int32
|
||||
bgHighlight PageItem
|
||||
highlightIndex int
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
target pageType
|
||||
label StringItem
|
||||
}
|
||||
|
||||
func (p *Settings) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Get shaders
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
// Initialize base
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Create title
|
||||
p.title, err = NewStringItem("Settings", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
|
||||
// Create prompt
|
||||
p.prompt, err = NewStringItem("Use Arrow Keys to Navigate, Enter to Select, or F1 to return to the Main Menu", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
|
||||
// Create options
|
||||
proclamationsLabel, err := NewStringItem("Proclamations", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating proclamations label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: PROCLAMATIONS,
|
||||
label: proclamationsLabel,
|
||||
})
|
||||
|
||||
audioLabel, err := NewStringItem("Audio", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating audio label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: AUDIO,
|
||||
label: audioLabel,
|
||||
})
|
||||
|
||||
emailLabel, err := NewStringItem("Email Address", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating email label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: EMAIL_ADDRESS,
|
||||
label: emailLabel,
|
||||
})
|
||||
|
||||
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating license key label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: LICENSE_KEY,
|
||||
label: licenseKeyLabel,
|
||||
})
|
||||
|
||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wifi label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: WIFI,
|
||||
label: wifiLabel,
|
||||
})
|
||||
|
||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating logs label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: LOGS,
|
||||
label: logsLabel,
|
||||
})
|
||||
|
||||
// TODO: Add entry for "Tutorial"
|
||||
// TODO: Add entry for "About"
|
||||
|
||||
// Get base font size from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
// Calculate scales
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
scale := float32(32) / float32(baseFontSize)
|
||||
|
||||
// Apply scales
|
||||
p.title.Scale(titleScale)
|
||||
p.prompt.Scale(scale)
|
||||
|
||||
for i := range p.options {
|
||||
p.options[i].label.Scale(scale)
|
||||
}
|
||||
|
||||
// Create background highlight using first option's dimensions
|
||||
p.bgHighlight = PageItem{
|
||||
ID: 0,
|
||||
W: p.options[0].label.W,
|
||||
H: p.options[0].label.H,
|
||||
Color: colors.White,
|
||||
}
|
||||
p.highlightIndex = 0
|
||||
|
||||
// Position title at bottom
|
||||
p.title.X = p.Base.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
// Position prompt above title
|
||||
p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2)
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
|
||||
// If this value starts at 0, nothing is further left than that...
|
||||
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
|
||||
|
||||
// Position options stacked upward from prompt
|
||||
for i, option := range p.options {
|
||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
|
||||
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
|
||||
// Then update furthestLeft
|
||||
furthestLeft = p.options[i].label.X
|
||||
}
|
||||
if i == 0 {
|
||||
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
||||
} else {
|
||||
p.options[i].label.Y = p.options[i-1].label.Y - option.label.H - 10
|
||||
}
|
||||
}
|
||||
|
||||
// Now, re-iterate over the options and justify everything to furthestLeft
|
||||
for i := range p.options {
|
||||
p.options[i].label.X = furthestLeft
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.options)-1 {
|
||||
p.highlightIndex++
|
||||
}
|
||||
case sdl.K_RETURN:
|
||||
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
|
||||
selectedOption := p.options[p.highlightIndex]
|
||||
p.Context.NavigateTo(selectedOption.target)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(HOME)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Render() error {
|
||||
// Render title and prompt
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
|
||||
// Render highlight behind selected option
|
||||
p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
p.options[p.highlightIndex].label.Position(),
|
||||
p.options[p.highlightIndex].label.RawDimensions(),
|
||||
p.bgHighlight.Color,
|
||||
)
|
||||
|
||||
// Render all options with appropriate colors
|
||||
for i, option := range p.options {
|
||||
if p.highlightIndex == i {
|
||||
option.label.Color = colors.Black
|
||||
} else {
|
||||
option.label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, option.label.ID, option.label.Position(), option.label.RawDimensions(), option.label.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
for i := range p.options {
|
||||
gl.DeleteTextures(1, &p.options[i].label.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user