Initial release: RadiantWave v1.0
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type AudioDevices struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
devices []StringItem
|
||||
highlightIndex int
|
||||
selectedIndex int
|
||||
verticalMargin int32
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Init(app ApplicationInterface) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-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(app); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
// Get font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
|
||||
}
|
||||
|
||||
// Calculate scales
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
itemScale := float32(32) / float32(baseFontSize)
|
||||
|
||||
// Create title
|
||||
p.title, err = NewStringItem("Select an Audio Output Device", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
// Create prompt
|
||||
p.prompt, err = NewStringItem("Use Arrow Keys to select, Enter to confirm", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(itemScale)
|
||||
|
||||
// Get list of audio output devices
|
||||
devices := mixer.ListDevices()
|
||||
if len(devices) == 0 {
|
||||
// Create a default entry so the page doesn't break
|
||||
devices = []string{"System Default"}
|
||||
}
|
||||
|
||||
// Get currently selected device from database
|
||||
currentDevice, err := db.GetConfigValue("audio_device_name")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
|
||||
}
|
||||
|
||||
// Create UI items for each device
|
||||
for i, name := range devices { // Iterate over the devices
|
||||
item, err := NewStringItem(name, p.font, colors.White) // Create a new string string texture
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create device item name: %w", err)
|
||||
}
|
||||
item.Scale(itemScale) // Scale the new string item
|
||||
p.devices = append(p.devices, item) // Add the new scaled string item to the list of items
|
||||
|
||||
// Mark currently selected device
|
||||
if name == currentDevice {
|
||||
p.selectedIndex = i
|
||||
p.highlightIndex = i // Start with cursor on current device
|
||||
}
|
||||
}
|
||||
|
||||
on, err := db.GetConfigValue("audio_enabled")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to query database for audio_enabled: %w", err)
|
||||
}
|
||||
|
||||
audioStatus := "Turn Audio On"
|
||||
if on == "true" {
|
||||
audioStatus = "Turn Audio Off"
|
||||
}
|
||||
|
||||
audioStatusStringItem, err := NewStringItem(audioStatus, p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Turn Audio Off string item: %w", err)
|
||||
}
|
||||
|
||||
// Scale the new option
|
||||
audioStatusStringItem.Scale(itemScale)
|
||||
|
||||
p.devices = append(p.devices, audioStatusStringItem)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) layout() {
|
||||
// Position title at bottom
|
||||
p.title.X = p.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 - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
|
||||
// Position device items stacked upward from prompt
|
||||
for i := range p.devices {
|
||||
p.devices[i].X = p.ScreenCenterX - p.devices[i].W/2
|
||||
if i == 0 {
|
||||
p.devices[i].Y = p.prompt.Y - p.verticalMargin*2 - p.devices[i].H
|
||||
} else {
|
||||
p.devices[i].Y = p.devices[i-1].Y - p.devices[i].H - 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AudioDevices) 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_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.devices)-1 {
|
||||
p.highlightIndex++
|
||||
}
|
||||
|
||||
case sdl.K_RETURN:
|
||||
selection := p.devices[p.highlightIndex].content
|
||||
|
||||
// Handle audio disable
|
||||
if selection == "Turn Audio Off" || selection == "Turn Audio On" {
|
||||
on, err := db.GetConfigValue("audio_enabled")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch audio_enabled from database: %w", err)
|
||||
}
|
||||
|
||||
currentStatus, err := strconv.ParseBool(on)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse audio_enabled value: %w", err)
|
||||
}
|
||||
|
||||
newStatus := strconv.FormatBool(!currentStatus)
|
||||
if err := db.SetConfigValue("audio_enabled", newStatus); err != nil {
|
||||
return fmt.Errorf("failed to update audio_enabled: %w", err)
|
||||
}
|
||||
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Map "System Default" to empty string for SDL
|
||||
deviceName := selection
|
||||
if selection == "System Default" {
|
||||
deviceName = ""
|
||||
}
|
||||
|
||||
// Validate by attempting the switch
|
||||
if err := mixer.SwitchDevice(deviceName); err != nil {
|
||||
return fmt.Errorf("failed to switch to device %q: %w", selection, err)
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if err := db.SetConfigValue("audio_device_name", deviceName); err != nil {
|
||||
return fmt.Errorf("failed to save audio device: %w", err)
|
||||
}
|
||||
if err := db.SetConfigValue("audio_enabled", "true"); err != nil {
|
||||
return fmt.Errorf("failed to enable audio: %w", err)
|
||||
}
|
||||
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
|
||||
case sdl.K_BACKSPACE:
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Render() error {
|
||||
// Always 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)
|
||||
|
||||
// No devices to display
|
||||
if len(p.devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render highlight box behind highlighted item
|
||||
p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
p.devices[p.highlightIndex].Position(),
|
||||
p.devices[p.highlightIndex].RawDimensions(),
|
||||
colors.White,
|
||||
)
|
||||
|
||||
// Render device items with appropriate colors
|
||||
for i, item := range p.devices {
|
||||
if i == p.highlightIndex && i == p.selectedIndex {
|
||||
// Currently highlighted AND selected device - dark green
|
||||
item.Color = colors.DarkGreen
|
||||
} else if i == p.highlightIndex {
|
||||
// Just highlighted - black (shows on white background)
|
||||
item.Color = colors.Black
|
||||
} else if i == p.selectedIndex {
|
||||
// Just selected - green
|
||||
item.Color = colors.Green
|
||||
} else {
|
||||
// Normal item - white
|
||||
item.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
for _, item := range p.devices {
|
||||
gl.DeleteTextures(1, &item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
type downloadState int
|
||||
|
||||
const (
|
||||
downloadStateDownloading downloadState = iota
|
||||
downloadStateFinished
|
||||
)
|
||||
|
||||
// DownloadProgressPage displays a progress bar while an audio file is downloading.
|
||||
// The page is inescapable: no input is accepted while downloading, and only
|
||||
// Enter is accepted once the download finishes.
|
||||
type DownloadProgressPage struct {
|
||||
Base
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
|
||||
// Set by the application before pushing this page.
|
||||
TotalBytes *atomic.Int64
|
||||
BytesWritten *atomic.Int64
|
||||
Done *atomic.Bool
|
||||
HasError *atomic.Bool
|
||||
NextPage Page // where to navigate on success
|
||||
|
||||
state downloadState
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
percentText StringItem
|
||||
lastPercent int
|
||||
|
||||
barX, barY, barW, barH int32
|
||||
}
|
||||
|
||||
func (p *DownloadProgressPage) Init(app ApplicationInterface) error {
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font")
|
||||
}
|
||||
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader")
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader")
|
||||
}
|
||||
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
titleScale := float32(48) / float32(baseFontSize)
|
||||
p.title, err = NewStringItem("Downloading Audio...", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
promptScale := float32(32) / float32(baseFontSize)
|
||||
p.prompt, err = NewStringItem("Press Enter to continue", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(promptScale)
|
||||
|
||||
p.percentText, err = NewStringItem("0%", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating percent text: %w", err)
|
||||
}
|
||||
p.percentText.Scale(promptScale)
|
||||
p.lastPercent = 0
|
||||
|
||||
// Progress bar dimensions: 60% of screen width, 30px tall, centered
|
||||
p.barW = int32(float32(p.ScreenWidth) * 0.6)
|
||||
p.barH = 30
|
||||
p.barX = p.ScreenCenterX - p.barW/2
|
||||
p.barY = p.ScreenCenterY - p.barH/2
|
||||
|
||||
// Position title above bar
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.barY + p.barH + 40
|
||||
|
||||
p.state = downloadStateDownloading
|
||||
keybinds.Enabled = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadProgressPage) Update(dt float32) error {
|
||||
if p.state == downloadStateFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.Done.Load() {
|
||||
p.state = downloadStateFinished
|
||||
|
||||
// Force progress to 100% on success
|
||||
if !p.HasError.Load() && p.TotalBytes.Load() > 0 {
|
||||
p.BytesWritten.Store(p.TotalBytes.Load())
|
||||
p.lastPercent = 100
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if ok {
|
||||
gl.DeleteTextures(1, &p.percentText.ID)
|
||||
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||
percentScale := float32(32) / float32(baseFontSize)
|
||||
newText, err := NewStringItem("100%", font, colors.White)
|
||||
if err == nil {
|
||||
newText.Scale(percentScale)
|
||||
p.percentText = newText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update title to reflect result
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||
titleScale := float32(48) / float32(baseFontSize)
|
||||
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
|
||||
var titleText string
|
||||
if p.HasError.Load() {
|
||||
titleText = "Download Failed"
|
||||
} else {
|
||||
titleText = "Download Complete"
|
||||
}
|
||||
newTitle, err := NewStringItem(titleText, font, colors.White)
|
||||
if err == nil {
|
||||
newTitle.Scale(titleScale)
|
||||
p.title = newTitle
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.barY + p.barH + 40
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update percentage texture when it changes
|
||||
percent := 0
|
||||
if p.TotalBytes.Load() > 0 {
|
||||
percent = int(p.BytesWritten.Load() * 100 / p.TotalBytes.Load())
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
|
||||
if percent != p.lastPercent {
|
||||
p.lastPercent = percent
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
gl.DeleteTextures(1, &p.percentText.ID)
|
||||
|
||||
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||
percentScale := float32(32) / float32(baseFontSize)
|
||||
|
||||
newText, err := NewStringItem(fmt.Sprintf("%d%%", percent), font, colors.White)
|
||||
if err == nil {
|
||||
newText.Scale(percentScale)
|
||||
p.percentText = newText
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadProgressPage) HandleEvent(event *sdl.Event) error {
|
||||
// Inescapable while downloading
|
||||
if p.state != downloadStateFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
if e.Keysym.Sym == sdl.K_RETURN {
|
||||
if p.HasError.Load() || p.NextPage == nil {
|
||||
p.App.SchedulePop()
|
||||
} else {
|
||||
p.App.SwitchPage(p.NextPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadProgressPage) Render() error {
|
||||
// Render title
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
|
||||
// Progress bar background (dark gray)
|
||||
bgColor := sdl.Color{R: 60, G: 60, B: 60, A: 255}
|
||||
barPos := mgl32.Vec2{float32(p.barX), float32(p.barY)}
|
||||
barDim := mgl32.Vec2{float32(p.barW), float32(p.barH)}
|
||||
p.RenderSolidColorQuad(p.solidShader, barPos, barDim, bgColor)
|
||||
|
||||
// Progress bar foreground
|
||||
progress := float32(0)
|
||||
if p.TotalBytes.Load() > 0 {
|
||||
progress = float32(p.BytesWritten.Load()) / float32(p.TotalBytes.Load())
|
||||
if progress > 1.0 {
|
||||
progress = 1.0
|
||||
}
|
||||
}
|
||||
if progress > 0 {
|
||||
fgColor := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
fgDim := mgl32.Vec2{float32(p.barW) * progress, float32(p.barH)}
|
||||
p.RenderSolidColorQuad(p.solidShader, barPos, fgDim, fgColor)
|
||||
}
|
||||
|
||||
// Render percentage text below bar
|
||||
p.percentText.X = p.ScreenCenterX - p.percentText.W/2
|
||||
p.percentText.Y = p.barY - p.percentText.H - 20
|
||||
p.RenderTexture(p.textShader, p.percentText.ID, p.percentText.Position(), p.percentText.RawDimensions(), p.percentText.Color)
|
||||
|
||||
// Show prompt when finished
|
||||
if p.state == downloadStateFinished {
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.barY - p.percentText.H - 20 - p.prompt.H - 20
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadProgressPage) Destroy() error {
|
||||
keybinds.Enabled = true
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.percentText.ID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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(app ApplicationInterface) error {
|
||||
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "Your Email Address"
|
||||
|
||||
// Get application font from database
|
||||
applicationFont, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
emailAddress, err := db.GetConfigValue("email_address")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving email_address from db: %w", err)
|
||||
}
|
||||
|
||||
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:
|
||||
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.App.UnwindToPage(&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
|
||||
if err := db.SetConfigValue("email_address", p.inputText); err != nil {
|
||||
return fmt.Errorf("failed to save email address: %w", err)
|
||||
}
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: navigate to License Key page
|
||||
p.App.PushPage(&LicenseKeyPage{})
|
||||
} else {
|
||||
// Coming from settings: rewind back to settings
|
||||
p.App.UnwindToPage(&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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Get base font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
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,458 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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
|
||||
}
|
||||
|
||||
// isValidLicenseKey checks if the license key has exactly 16 alphanumeric characters
|
||||
func isValidLicenseKey(key string) bool {
|
||||
cleaned := strings.ReplaceAll(key, "-", "")
|
||||
if len(cleaned) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, r := range cleaned {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *LicenseKeyPage) Init(app ApplicationInterface) error {
|
||||
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "License Key"
|
||||
|
||||
// Get application font from database
|
||||
applicationFont, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
licenseKey, err := db.GetConfigValue("license_key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving license_key from db: %w", err)
|
||||
}
|
||||
|
||||
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:
|
||||
// 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.App.UnwindToPage(&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
|
||||
if err := db.SetConfigValue("license_key", p.inputText); err != nil {
|
||||
return fmt.Errorf("failed to save license key: %w", err)
|
||||
}
|
||||
|
||||
// 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.App.UnwindToPage(&Welcome{})
|
||||
} else {
|
||||
// Coming from settings: rewind back to settings
|
||||
p.App.UnwindToPage(&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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Get base font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
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,251 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type LogViewerPage struct {
|
||||
Base
|
||||
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
lines []string
|
||||
lineTextureIDs []uint32
|
||||
lineTextureWidths []int32
|
||||
lineTextureHeights []int32
|
||||
|
||||
hintTextureID uint32
|
||||
hintTextureWidth int32
|
||||
hintTextureHeight int32
|
||||
|
||||
scrollOffset int32
|
||||
maxScrollOffset int32
|
||||
lineHeight int32
|
||||
visibleHeight int32
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Init(app ApplicationInterface) error {
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
logs, err := db.GetLogs(1000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
|
||||
// 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")...)
|
||||
}
|
||||
|
||||
applicationFont, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
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.App.SchedulePop()
|
||||
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 {
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
sm := shaderManager.Get()
|
||||
textShader, _ := sm.Get("text")
|
||||
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
|
||||
|
||||
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
|
||||
|
||||
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
|
||||
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
|
||||
|
||||
// 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}, 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}, grey)
|
||||
|
||||
// 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
|
||||
|
||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
|
||||
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,198 @@
|
||||
// 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(app ApplicationInterface) error
|
||||
HandleEvent(event *sdl.Event) error
|
||||
Update(dt float32) error
|
||||
Render() error
|
||||
// Destroy cleans up the page's resources. The Application's page manager
|
||||
// is responsible for calling this method
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
// ApplicationInterface defines the set of methods that a page can use to
|
||||
// interact with the main application.
|
||||
type ApplicationInterface interface {
|
||||
SwitchPage(p Page)
|
||||
PushPage(p Page)
|
||||
UnwindToPage(p Page)
|
||||
GetDrawableSize() (int32, int32)
|
||||
SchedulePop()
|
||||
Quit()
|
||||
}
|
||||
|
||||
// Base is a helper struct that pages can embed to get common functionality.
|
||||
type Base struct {
|
||||
App ApplicationInterface
|
||||
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(app ApplicationInterface) error {
|
||||
p.App = app
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
screenWidth, screenHeight := p.App.GetDrawableSize()
|
||||
p.Projection = mgl32.Ortho(0, float32(screenWidth), 0, float32(screenHeight), -1, 1)
|
||||
p.ScreenHeight = screenHeight
|
||||
p.ScreenWidth = screenWidth
|
||||
p.ScreenCenterX = screenWidth / 2
|
||||
p.ScreenCenterY = screenHeight / 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 }
|
||||
@@ -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,243 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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 ProclamationOptions struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
proclamations []StringItem
|
||||
proclamationIDs []uint // Database IDs corresponding to each proclamation
|
||||
activeMap map[int]bool // Maps display index to selected state
|
||||
highlightIndex int
|
||||
verticalMargin int32
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Init(app ApplicationInterface) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.activeMap = make(map[int]bool)
|
||||
p.proclamationIDs = make([]uint, 0)
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-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(app); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
// Get font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
|
||||
}
|
||||
|
||||
// Calculate scales
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
itemScale := float32(32) / float32(baseFontSize)
|
||||
|
||||
// Create title
|
||||
p.title, err = NewStringItem("Select Proclamations", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
// Create prompt
|
||||
p.prompt, err = NewStringItem("Arrow Keys to navigate | Space to toggle | F2 to run | Enter to return", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(itemScale)
|
||||
|
||||
// Load proclamations from database
|
||||
proclamations, err := db.GetProclamations()
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving proclamations from db: %w", err)
|
||||
}
|
||||
|
||||
// Create UI items for each available proclamation
|
||||
for _, proclamation := range proclamations {
|
||||
if !proclamation.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
label, err := NewStringItem(proclamation.Title, p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating proclamation label: %w", err)
|
||||
}
|
||||
|
||||
label.Scale(itemScale)
|
||||
p.proclamations = append(p.proclamations, label)
|
||||
p.proclamationIDs = append(p.proclamationIDs, proclamation.ID)
|
||||
|
||||
// Set initial selection state
|
||||
if proclamation.Selected {
|
||||
p.activeMap[len(p.proclamations)-1] = true
|
||||
}
|
||||
}
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) layout() {
|
||||
// Position title at bottom
|
||||
p.title.X = p.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 - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
|
||||
// Position proclamation items stacked upward from prompt
|
||||
for i := range p.proclamations {
|
||||
p.proclamations[i].X = p.ScreenCenterX - p.proclamations[i].W/2
|
||||
if i == 0 {
|
||||
p.proclamations[i].Y = p.prompt.Y - p.verticalMargin*2 - p.proclamations[i].H
|
||||
} else {
|
||||
p.proclamations[i].Y = p.proclamations[i-1].Y - p.proclamations[i].H - 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) 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.proclamations)-1 {
|
||||
p.highlightIndex++
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
case sdl.K_SPACE:
|
||||
// Toggle selection for highlighted item
|
||||
if p.activeMap[p.highlightIndex] {
|
||||
delete(p.activeMap, p.highlightIndex)
|
||||
} else {
|
||||
p.activeMap[p.highlightIndex] = true
|
||||
}
|
||||
|
||||
// Immediately save to database
|
||||
if err := p.toggleProclamation(p.highlightIndex); err != nil {
|
||||
return fmt.Errorf("failed to toggle proclamation: %w", err)
|
||||
}
|
||||
case sdl.K_RETURN:
|
||||
// Just return to settings - no saving needed
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleProclamation immediately updates the database for the proclamation at the given display index
|
||||
func (p *ProclamationOptions) toggleProclamation(displayIndex int) error {
|
||||
if displayIndex < 0 || displayIndex >= len(p.proclamationIDs) {
|
||||
return fmt.Errorf("invalid display index %d", displayIndex)
|
||||
}
|
||||
|
||||
dbID := p.proclamationIDs[displayIndex]
|
||||
isSelected := p.activeMap[displayIndex]
|
||||
|
||||
// Get the proclamation from database
|
||||
var proclamation db.Proclamations
|
||||
if err := db.DB.First(&proclamation, dbID).Error; err != nil {
|
||||
return fmt.Errorf("retrieving proclamation %d: %w", dbID, err)
|
||||
}
|
||||
|
||||
// Update the Selected field
|
||||
proclamation.Selected = isSelected
|
||||
if err := db.DB.Save(&proclamation).Error; err != nil {
|
||||
return fmt.Errorf("updating proclamation %d: %w", dbID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Render() error {
|
||||
// Always 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)
|
||||
|
||||
// No proclamations to display
|
||||
if len(p.proclamations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render highlight box behind selected item
|
||||
p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
p.proclamations[p.highlightIndex].Position(),
|
||||
p.proclamations[p.highlightIndex].RawDimensions(),
|
||||
colors.White,
|
||||
)
|
||||
|
||||
// Render proclamation items with appropriate colors
|
||||
for i, item := range p.proclamations {
|
||||
if p.activeMap[i] {
|
||||
// Selected items are green
|
||||
item.Color = colors.Green
|
||||
} else if i == p.highlightIndex {
|
||||
// Highlighted item is black (shows on white background)
|
||||
item.Color = colors.Black
|
||||
} else {
|
||||
// Normal items are white
|
||||
item.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
for _, item := range p.proclamations {
|
||||
gl.DeleteTextures(1, &item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
// ATTENTION: TED
|
||||
//
|
||||
// # Integrated Multi-Domain Rhema Wave Generation System
|
||||
//
|
||||
// This module implements the complete Rhema Wave generation system by synchronizing multiple
|
||||
// subsystems that operate simultaneously across spatial, temporal, and chromatic
|
||||
// domains to produce a unified frequency-structured visual output for subliminal proclamation display.
|
||||
//
|
||||
// The technology addresses the fundamental challenge of presenting textual proclamation content
|
||||
// in a format optimized for subconscious processing rather than conscious reading. Conventional
|
||||
// subliminal text systems use isolated techniques (simple masking, single-frequency flashing, or
|
||||
// uniform scrolling) that operate in only one domain. Our system integrates four mathematically-
|
||||
// synchronized subsystems, each generating Fibonacci-based patterns in different domains, creating
|
||||
// a compound frequency signature designed to enhance subconscious absorption while maintaining
|
||||
// conscious illegibility.
|
||||
//
|
||||
// The system architecture integrates:
|
||||
//
|
||||
// 1. Spatial Character Transformation - Each rendered character undergoes Fibonacci-based
|
||||
// block scrambling (described separately), creating local spatial frequency patterns at
|
||||
// the character level.
|
||||
//
|
||||
// 2. Spatial Line Distribution - Text lines contain varying word counts following an oscillating
|
||||
// Fibonacci sequence (1, 1, 2, 3, 5, 8, 13... then reversing), creating a macro-level
|
||||
// two-dimensional wave pattern in the display geometry and ensuring balanced temporal
|
||||
// exposure of proclamation content segments.
|
||||
//
|
||||
// 3. Temporal Color Modulation - Sequential words receive colors from an intent-based palette,
|
||||
// with color duration determined by an oscillating Fibonacci sequence, creating rhythmic
|
||||
// chromatic variation synchronized with natural mathematical progression.
|
||||
//
|
||||
// 4. Temporal Velocity Modulation - Display scroll velocity oscillates between negative and
|
||||
// positive extremes with acceleration/deceleration rates and hold periods, creating a
|
||||
// "breathing" temporal rhythm for the entire visual field.
|
||||
//
|
||||
// The key innovation lies in the temporal and mathematical synchronization of these subsystems.
|
||||
// All four operate on Fibonacci-derived parameters, creating harmonic relationships between the
|
||||
// spatial frequencies (character scrambling, line wave patterns), chromatic frequencies (color
|
||||
// duration oscillation), and temporal frequencies (scroll velocity modulation). This multi-domain
|
||||
// integration produces what we term the "Rhema Wave" - a compound frequency signature where
|
||||
// spatial, temporal, and chromatic patterns reinforce each other through mathematical harmony.
|
||||
//
|
||||
// Key differentiators from existing solutions:
|
||||
// - Integrates four distinct Fibonacci-based frequency generators in a single system
|
||||
// - Synchronizes pattern generation across spatial, temporal, and chromatic domains
|
||||
// - Creates compound frequency signatures rather than isolated single-domain effects
|
||||
// - Maintains character-specific and content-specific mathematical structure throughout
|
||||
// - Uses bidirectional oscillation across multiple subsystems for rhythmic coherence
|
||||
// - Combines macro-level (line patterns, velocity) and micro-level (character, color) modulation
|
||||
//
|
||||
// The system loads proclamation text content, processes it through all four subsystems simultaneously,
|
||||
// and generates a continuously scrolling display where every element - from individual character
|
||||
// pixels to overall scroll rhythm - contributes to the unified Rhema Wave output. The mathematical
|
||||
// relationships between subsystems create interference patterns and harmonic resonances designed
|
||||
// to enhance subconscious processing of the proclamation content while maintaining conscious
|
||||
// illegibility through the character scrambling subsystem.
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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/mixer"
|
||||
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
||||
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
words []StringItem
|
||||
width int32
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type ScrollerPage struct {
|
||||
Base
|
||||
|
||||
words []string
|
||||
wordIndex int
|
||||
lines *LineDeque
|
||||
lineHeight int32
|
||||
fontScale float32
|
||||
shader *shaderManager.Shader
|
||||
activePattern linePattern.LinePattern
|
||||
font *fontManager.FontEntry
|
||||
spaceWidth int32
|
||||
lineCtx *linePattern.LineRenderContext
|
||||
colorPattern *colorPattern.ColorPattern
|
||||
scrollOffset float32
|
||||
scrollY float32
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Init(app ApplicationInterface) error {
|
||||
p.increment = 100.0
|
||||
p.velocity = 800.0
|
||||
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
||||
|
||||
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
|
||||
|
||||
p.Base.Init(app)
|
||||
|
||||
// Get text shader
|
||||
textShader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("text shader not found")
|
||||
}
|
||||
p.shader = textShader
|
||||
|
||||
// Load proclamation words from database
|
||||
if err := p.loadProclamations(); err != nil {
|
||||
// Don't stop execution, just show fallback words
|
||||
p.words = strings.Fields("No Proclamations Selected!")
|
||||
}
|
||||
p.wordIndex = 0
|
||||
|
||||
// Get line pattern from database
|
||||
linePatternName, err := db.GetConfigValue("line_pattern")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving line_pattern from db: %w", err)
|
||||
}
|
||||
|
||||
p.activePattern, err = linePattern.Get(linePatternName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
||||
}
|
||||
|
||||
// Initialize color pattern
|
||||
var baseColors = []sdl.Color{
|
||||
colors.Tan,
|
||||
colors.PeachPuff,
|
||||
colors.LightCyan,
|
||||
colors.LightSeaGreen,
|
||||
colors.MediumTurquoise,
|
||||
colors.DarkCyan,
|
||||
colors.MediumPurple,
|
||||
}
|
||||
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
||||
|
||||
// Get font sizes from database (optimize: load once, cache as int32)
|
||||
standardFontSizeStr, err := db.GetConfigValue("standard_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving standard_font_size from db: %w", err)
|
||||
}
|
||||
standardFontSize, err := strconv.Atoi(standardFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing standard_font_size: %w", err)
|
||||
}
|
||||
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
// Font setup (optimized: calculate once, reuse)
|
||||
p.fontScale = float32(standardFontSize) / float32(baseFontSize)
|
||||
nLines := int(p.ScreenHeight / int32(standardFontSize))
|
||||
p.lines = NewLineDeque(nLines * 2)
|
||||
|
||||
// Calculate space width once
|
||||
spaceGlyph, ok := fm.GetGlyph(p.font.Name, ' ')
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get space glyph")
|
||||
}
|
||||
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
||||
|
||||
// Get line height from a sample string
|
||||
tmpStrID, _, H, err := fm.NewCreateStringTexture(p.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: p.font,
|
||||
FontScale: p.fontScale,
|
||||
SpaceWidth: p.spaceWidth,
|
||||
}
|
||||
|
||||
// Create initial lines
|
||||
for i := 0; i < nLines; i++ {
|
||||
if err := p.createLine(i, true); err != nil {
|
||||
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audio setup
|
||||
audioDeviceName, err := db.GetConfigValue("audio_device_name")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
|
||||
}
|
||||
|
||||
// Switch audio devices if needed
|
||||
if mixer.CurrentDevice() != audioDeviceName {
|
||||
if err := mixer.SwitchDevice(audioDeviceName); err != nil {
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
// Start audio playback
|
||||
selectedAudio, err := db.GetConfigValue("selected_audio")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving selected_audio from db: %w", err)
|
||||
}
|
||||
|
||||
assetsDir, err := db.GetConfigValue("assets_dir")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving assets_dir from db: %w", err)
|
||||
}
|
||||
|
||||
if selectedAudio == "" {
|
||||
} else {
|
||||
if err := mixer.Play(filepath.Join(assetsDir, selectedAudio), true); err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
currentDevice := mixer.CurrentDevice()
|
||||
if currentDevice == "" {
|
||||
currentDevice = "default"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadProclamations loads selected proclamations from the database
|
||||
func (p *ScrollerPage) loadProclamations() error {
|
||||
p.words = []string{}
|
||||
|
||||
// Get all selected proclamations from database
|
||||
proclamations, err := db.GetProclamations()
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving proclamations from db: %w", err)
|
||||
}
|
||||
|
||||
// Build word list from selected, available proclamations
|
||||
for _, proclamation := range proclamations {
|
||||
if proclamation.Selected && proclamation.Available {
|
||||
// Split content into words and add to list
|
||||
words := strings.Fields(proclamation.Content)
|
||||
p.words = append(p.words, words...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.words) == 0 {
|
||||
p.words = strings.Fields("No Proclamations Selected!")
|
||||
return fmt.Errorf("no proclamations selected or available")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||
line := Line{words: []StringItem{}}
|
||||
|
||||
for _, word := range words {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||
}
|
||||
item.W = int32(float32(item.W) * p.fontScale)
|
||||
item.H = int32(float32(item.H) * p.fontScale)
|
||||
|
||||
if len(line.words) > 0 {
|
||||
line.width += p.spaceWidth
|
||||
}
|
||||
line.words = append(line.words, item)
|
||||
line.width += item.W
|
||||
}
|
||||
|
||||
// Calculate horizontal position (centered)
|
||||
startX := p.ScreenCenterX - (line.width / 2)
|
||||
|
||||
// Calculate vertical position
|
||||
var baseY int32
|
||||
if isInitial {
|
||||
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
||||
} else {
|
||||
baseY = 0 // Will be set by caller based on context
|
||||
}
|
||||
|
||||
// Position all words in the line
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
case sdl.K_a:
|
||||
p.autoVelocity = !p.autoVelocity
|
||||
if p.autoVelocity {
|
||||
// Reset to initial state when enabling
|
||||
p.velocity = p.velocityFloor
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
}
|
||||
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 {
|
||||
old := p.lines.PopFront()
|
||||
for _, word := range old.words {
|
||||
gl.DeleteTextures(1, &word.ID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
old := p.lines.PopBack()
|
||||
for _, word := range old.words {
|
||||
gl.DeleteTextures(1, &word.ID)
|
||||
}
|
||||
|
||||
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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||
line := Line{words: []StringItem{}}
|
||||
|
||||
for _, word := range newWords {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||
}
|
||||
item.W = int32(float32(item.W) * p.fontScale)
|
||||
item.H = int32(float32(item.H) * p.fontScale)
|
||||
|
||||
if len(line.words) > 0 {
|
||||
line.width += p.spaceWidth
|
||||
}
|
||||
line.words = append(line.words, item)
|
||||
line.width += item.W
|
||||
}
|
||||
|
||||
startX := p.ScreenCenterX - (line.width / 2)
|
||||
|
||||
// Position relative to last existing line
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||
line := Line{words: []StringItem{}}
|
||||
|
||||
for _, word := range newWords {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||
}
|
||||
item.W = int32(float32(item.W) * p.fontScale)
|
||||
item.H = int32(float32(item.H) * p.fontScale)
|
||||
|
||||
if len(line.words) > 0 {
|
||||
line.width += p.spaceWidth
|
||||
}
|
||||
line.words = append(line.words, item)
|
||||
line.width += item.W
|
||||
}
|
||||
|
||||
startX := p.ScreenCenterX - (line.width / 2)
|
||||
|
||||
// Position relative to first existing line
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
mixer.Stop(1000)
|
||||
|
||||
// GL cleanup — delete all text textures
|
||||
for i := 0; i < p.lines.Len(); i++ {
|
||||
line := p.lines.Get(i)
|
||||
for j := range line.words {
|
||||
if id := line.words[j].ID; id != 0 {
|
||||
gl.DeleteTextures(1, &line.words[j].ID)
|
||||
line.words[j].ID = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gl.Flush()
|
||||
|
||||
// Drop references for GC
|
||||
p.lines = nil
|
||||
p.words = nil
|
||||
p.lineCtx = nil
|
||||
p.font = nil
|
||||
p.shader = nil
|
||||
p.colorPattern = 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
|
||||
}
|
||||
|
||||
// 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,253 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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 {
|
||||
page Page
|
||||
label StringItem
|
||||
}
|
||||
|
||||
func (p *Settings) Init(app ApplicationInterface) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-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(app); 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{
|
||||
page: &ProclamationOptions{},
|
||||
label: proclamationsLabel,
|
||||
})
|
||||
|
||||
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{
|
||||
page: &WiFiSetupPage{},
|
||||
label: wifiLabel,
|
||||
})
|
||||
|
||||
audioLabel, err := NewStringItem("Audio Device", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating audio label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
page: &AudioDevices{},
|
||||
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{
|
||||
page: &EmailAddressPage{},
|
||||
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{
|
||||
page: &LicenseKeyPage{},
|
||||
label: licenseKeyLabel,
|
||||
})
|
||||
|
||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating logs label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
page: &LogViewerPage{},
|
||||
label: logsLabel,
|
||||
})
|
||||
|
||||
updateLabel, err := NewStringItem("Check for Updates", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating update label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
page: &UpdateCheckPage{},
|
||||
label: updateLabel,
|
||||
})
|
||||
|
||||
// Get base font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
// 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 - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
|
||||
// Position options stacked upward from prompt
|
||||
for i, option := range p.options {
|
||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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.App.SwitchPage(selectedOption.page)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
p.App.SwitchPage(&Welcome{})
|
||||
}
|
||||
}
|
||||
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,388 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type TOSPage struct {
|
||||
Base
|
||||
|
||||
Title string
|
||||
Content string
|
||||
ConfigKey string
|
||||
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
lines []string
|
||||
lineTextureIDs []uint32
|
||||
lineTextureWidths []int32
|
||||
lineTextureHeights []int32
|
||||
|
||||
promptTextureID uint32
|
||||
promptTextureWidth int32
|
||||
promptTextureHeight int32
|
||||
|
||||
scrollHintTextureID uint32
|
||||
scrollHintTextureWidth int32
|
||||
scrollHintTextureHeight int32
|
||||
|
||||
scrollOffset int32
|
||||
maxScrollOffset int32
|
||||
lineHeight int32
|
||||
visibleHeight int32
|
||||
}
|
||||
|
||||
func (p *TOSPage) Init(app ApplicationInterface) error {
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.lines = parseHTMLToLines(p.Content, 60)
|
||||
|
||||
applicationFont, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.Title, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
p.titleTextureID = titleTextureID
|
||||
p.titleTextureWidth = titleWidth
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
promptText := "Press Y to Accept | Press N to Decline"
|
||||
promptTextureID, promptWidth, promptHeight, err := fm.CreateStringTexture(applicationFont, promptText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create prompt texture: %w", err)
|
||||
}
|
||||
p.promptTextureID = promptTextureID
|
||||
p.promptTextureWidth = promptWidth
|
||||
p.promptTextureHeight = promptHeight
|
||||
|
||||
scrollHintText := "Scroll down: Arrow Keys / PgDn"
|
||||
scrollHintID, scrollHintW, scrollHintH, err := fm.CreateStringTexture(applicationFont, scrollHintText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create scroll hint texture: %w", err)
|
||||
}
|
||||
p.scrollHintTextureID = scrollHintID
|
||||
p.scrollHintTextureWidth = scrollHintW
|
||||
p.scrollHintTextureHeight = scrollHintH
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseHTMLToLines converts HTML content to plain-text lines for rendering.
|
||||
// Supported tags: <p>, <br>, <li>, <h1>-<h6>. All other tags are stripped.
|
||||
// HTML entities & < > " ' are decoded.
|
||||
func parseHTMLToLines(html string, maxCharsPerLine int) []string {
|
||||
var lines []string
|
||||
|
||||
// Replace <br>, <br/>, <br /> with newline markers
|
||||
br := strings.NewReplacer(
|
||||
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
||||
"<BR>", "\n", "<BR/>", "\n", "<BR />", "\n",
|
||||
)
|
||||
html = br.Replace(html)
|
||||
|
||||
// Process block-level tags
|
||||
// Replace </p>, </h1>-</h6> with paragraph break marker
|
||||
for _, tag := range []string{"</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} {
|
||||
html = strings.ReplaceAll(html, tag, "\n\n")
|
||||
html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
|
||||
}
|
||||
|
||||
// Replace <li> with list prefix marker
|
||||
html = strings.ReplaceAll(html, "<li>", "\n- ")
|
||||
html = strings.ReplaceAll(html, "<LI>", "\n- ")
|
||||
|
||||
// Strip all remaining HTML tags
|
||||
var stripped strings.Builder
|
||||
inTag := false
|
||||
for _, r := range html {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
stripped.WriteRune(r)
|
||||
}
|
||||
}
|
||||
text := stripped.String()
|
||||
|
||||
// Decode HTML entities
|
||||
text = strings.ReplaceAll(text, "&", "&")
|
||||
text = strings.ReplaceAll(text, "<", "<")
|
||||
text = strings.ReplaceAll(text, ">", ">")
|
||||
text = strings.ReplaceAll(text, """, "\"")
|
||||
text = strings.ReplaceAll(text, "'", "'")
|
||||
|
||||
// Split into segments on double-newline (paragraph breaks) and single newline (line breaks)
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
for i, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if para == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle single newlines within a paragraph (from <br> tags)
|
||||
sublines := strings.Split(para, "\n")
|
||||
for _, sub := range sublines {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if sub == "" {
|
||||
continue
|
||||
}
|
||||
wrapped := wrapText(sub, maxCharsPerLine)
|
||||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
||||
}
|
||||
|
||||
// Add paragraph break (empty line) between paragraphs
|
||||
if i < len(paragraphs)-1 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func wrapText(text string, maxCharsPerLine int) string {
|
||||
var result strings.Builder
|
||||
words := strings.Fields(text)
|
||||
lineLen := 0
|
||||
|
||||
for _, word := range words {
|
||||
if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 {
|
||||
result.WriteString("\n")
|
||||
lineLen = 0
|
||||
}
|
||||
if lineLen > 0 {
|
||||
result.WriteString(" ")
|
||||
lineLen++
|
||||
}
|
||||
result.WriteString(word)
|
||||
lineLen += len(word)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (p *TOSPage) HandleEvent(event *sdl.Event) error {
|
||||
actualEvent := *event
|
||||
|
||||
switch e := actualEvent.(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_n:
|
||||
logger.Info("User declined TOS (%s); exiting", p.ConfigKey)
|
||||
p.App.Quit()
|
||||
case sdl.K_y:
|
||||
if p.scrollOffset < p.maxScrollOffset {
|
||||
return nil
|
||||
}
|
||||
logger.Info("User accepted TOS (%s)", p.ConfigKey)
|
||||
if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil {
|
||||
return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err)
|
||||
}
|
||||
p.App.SchedulePop()
|
||||
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 *TOSPage) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TOSPage) Render() error {
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
sm := shaderManager.Get()
|
||||
textShader, _ := sm.Get("text")
|
||||
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
|
||||
|
||||
titleFontSize := int32(48)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
contentFontSize := int32(32)
|
||||
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
||||
promptFontSize := int32(20)
|
||||
promptScale := float32(promptFontSize) / float32(baseFontSize)
|
||||
|
||||
margin := int32(40)
|
||||
lineSpacing := int32(4)
|
||||
paragraphSpacing := int32(16)
|
||||
p.lineHeight = contentFontSize + lineSpacing
|
||||
|
||||
// Center a content column at ~55% of screen width, left-align text within it
|
||||
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
|
||||
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
|
||||
|
||||
// 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}, white)
|
||||
|
||||
// Prompt dimensions (calculate even if not shown, for content bounds)
|
||||
promptH := float32(p.promptTextureHeight) * promptScale
|
||||
promptW := float32(p.promptTextureWidth) * promptScale
|
||||
promptX := p.Base.ScreenCenterX - int32(promptW/2)
|
||||
promptY := margin
|
||||
|
||||
// Content area bounds
|
||||
contentTop := titleY - margin
|
||||
contentBottom := promptY + int32(promptH) + margin
|
||||
p.visibleHeight = contentTop - contentBottom
|
||||
|
||||
// Calculate total content height (accounting for paragraph breaks)
|
||||
totalContentHeight := int32(0)
|
||||
for _, line := range p.lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
totalContentHeight += paragraphSpacing
|
||||
} else {
|
||||
totalContentHeight += 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 -= paragraphSpacing
|
||||
continue
|
||||
}
|
||||
|
||||
if p.lineTextureIDs[i] == 0 {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip lines outside visible area
|
||||
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
||||
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
||||
|
||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
|
||||
currentY -= p.lineHeight
|
||||
}
|
||||
|
||||
// Show accept/decline prompt when scrolled to bottom, scroll hint otherwise
|
||||
if p.scrollOffset >= p.maxScrollOffset {
|
||||
p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey)
|
||||
} else {
|
||||
hintScale := promptScale
|
||||
hintW := float32(p.scrollHintTextureWidth) * hintScale
|
||||
hintH := float32(p.scrollHintTextureHeight) * hintScale
|
||||
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
||||
hintY := promptY
|
||||
p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TOSPage) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
gl.DeleteTextures(1, &p.promptTextureID)
|
||||
gl.DeleteTextures(1, &p.scrollHintTextureID)
|
||||
|
||||
for _, texID := range p.lineTextureIDs {
|
||||
if texID != 0 {
|
||||
gl.DeleteTextures(1, &texID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
type updateCheckState int
|
||||
|
||||
const (
|
||||
updateCheckStateChecking updateCheckState = iota
|
||||
updateCheckStateDone
|
||||
)
|
||||
|
||||
type UpdateCheckPage struct {
|
||||
Base
|
||||
textShader *shaderManager.Shader
|
||||
|
||||
state updateCheckState
|
||||
|
||||
// "Checking for Updates." / ".." / "..."
|
||||
checkingTextures [3]StringItem
|
||||
|
||||
// "No updates available" and "Press F3 to return"
|
||||
doneMessage StringItem
|
||||
doneHint StringItem
|
||||
|
||||
// Error message if service failed to start
|
||||
errorMessage StringItem
|
||||
hasError bool
|
||||
|
||||
pollTimer float32
|
||||
dotIndex int
|
||||
dotTimer float32
|
||||
}
|
||||
|
||||
func (p *UpdateCheckPage) Init(app ApplicationInterface) error {
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font")
|
||||
}
|
||||
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader")
|
||||
}
|
||||
|
||||
// Get scale
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
scale := float32(48) / float32(baseFontSize)
|
||||
|
||||
// Create checking textures with animated dots
|
||||
dots := []string{"Checking for Updates.", "Checking for Updates..", "Checking for Updates..."}
|
||||
for i, text := range dots {
|
||||
item, err := NewStringItem(text, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating checking texture %d: %w", i, err)
|
||||
}
|
||||
item.Scale(scale)
|
||||
p.checkingTextures[i] = item
|
||||
}
|
||||
|
||||
// Create done textures
|
||||
hintScale := float32(32) / float32(baseFontSize)
|
||||
|
||||
p.doneMessage, err = NewStringItem("No updates available", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating done message: %w", err)
|
||||
}
|
||||
p.doneMessage.Scale(scale)
|
||||
|
||||
p.doneHint, err = NewStringItem("Press F3 to return", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating done hint: %w", err)
|
||||
}
|
||||
p.doneHint.Scale(hintScale)
|
||||
|
||||
// Start the update service
|
||||
p.state = updateCheckStateChecking
|
||||
if err := exec.Command("systemctl", "start", "rhemawave-update.service").Run(); err != nil {
|
||||
p.errorMessage, _ = NewStringItem("Update service unavailable", font, colors.White)
|
||||
p.errorMessage.Scale(scale)
|
||||
p.hasError = true
|
||||
p.state = updateCheckStateDone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UpdateCheckPage) Update(dt float32) error {
|
||||
if p.state != updateCheckStateChecking {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Animate dots
|
||||
p.dotTimer += dt
|
||||
if p.dotTimer >= 0.5 {
|
||||
p.dotTimer -= 0.5
|
||||
p.dotIndex = (p.dotIndex + 1) % 3
|
||||
}
|
||||
|
||||
// Poll service status every 2 seconds
|
||||
p.pollTimer += dt
|
||||
if p.pollTimer >= 2.0 {
|
||||
p.pollTimer -= 2.0
|
||||
|
||||
output, _ := exec.Command("systemctl", "is-active", "rhemawave-update.service").Output()
|
||||
status := strings.TrimSpace(string(output))
|
||||
if status != "active" && status != "activating" {
|
||||
p.state = updateCheckStateDone
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error {
|
||||
if p.state != updateCheckStateDone {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_F3, sdl.K_BACKSPACE:
|
||||
p.App.UnwindToPage(&Settings{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UpdateCheckPage) Render() error {
|
||||
switch p.state {
|
||||
case updateCheckStateChecking:
|
||||
item := p.checkingTextures[p.dotIndex]
|
||||
item.X = p.ScreenCenterX - item.W/2
|
||||
item.Y = p.ScreenCenterY - item.H/2
|
||||
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||
|
||||
case updateCheckStateDone:
|
||||
var msg *StringItem
|
||||
if p.hasError {
|
||||
msg = &p.errorMessage
|
||||
} else {
|
||||
msg = &p.doneMessage
|
||||
}
|
||||
|
||||
msg.X = p.ScreenCenterX - msg.W/2
|
||||
msg.Y = p.ScreenCenterY + msg.H/2
|
||||
p.RenderTexture(p.textShader, msg.ID, msg.Position(), msg.RawDimensions(), msg.Color)
|
||||
|
||||
p.doneHint.X = p.ScreenCenterX - p.doneHint.W/2
|
||||
p.doneHint.Y = msg.Y - p.doneHint.H - 20
|
||||
p.RenderTexture(p.textShader, p.doneHint.ID, p.doneHint.Position(), p.doneHint.RawDimensions(), p.doneHint.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UpdateCheckPage) Destroy() error {
|
||||
for i := range p.checkingTextures {
|
||||
gl.DeleteTextures(1, &p.checkingTextures[i].ID)
|
||||
}
|
||||
gl.DeleteTextures(1, &p.doneMessage.ID)
|
||||
gl.DeleteTextures(1, &p.doneHint.ID)
|
||||
if p.hasError {
|
||||
gl.DeleteTextures(1, &p.errorMessage.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "dev"
|
||||
|
||||
type Welcome struct {
|
||||
Base
|
||||
title StringItem
|
||||
version StringItem
|
||||
prompt StringItem
|
||||
note StringItem
|
||||
licenseInfoHeading StringItem
|
||||
emailDisplay StringItem
|
||||
licenseKeyDisplay StringItem
|
||||
displayStatus StringItem
|
||||
sdlDisplayStatus StringItem
|
||||
hasLicenseInfo bool
|
||||
lastDisplayUpdate time.Time
|
||||
drmDisplayCount int
|
||||
sdlDisplayCount int
|
||||
baseFontSize int // cached from db at init
|
||||
}
|
||||
|
||||
// countConnectedDisplays returns the number of connected displays via DRM
|
||||
func countConnectedDisplays() int {
|
||||
matches, err := filepath.Glob("/sys/class/drm/card*/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
connected := 0
|
||||
for _, path := range matches {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(string(content))
|
||||
if status == "connected" {
|
||||
connected++
|
||||
}
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
// countSDLDisplays returns the number of displays detected by SDL
|
||||
func countSDLDisplays() int {
|
||||
numDisplays, err := sdl.GetNumVideoDisplays()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return numDisplays
|
||||
}
|
||||
|
||||
// updateDisplayCounts updates the display count textures
|
||||
func (p *Welcome) updateDisplayCounts() error {
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch font")
|
||||
}
|
||||
|
||||
// Get base font size
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
// Update DRM count
|
||||
p.drmDisplayCount = countConnectedDisplays()
|
||||
|
||||
// Delete old texture
|
||||
if p.displayStatus.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.displayStatus.ID)
|
||||
}
|
||||
|
||||
// Create new texture
|
||||
displayStatusText := fmt.Sprintf("Connected Displays: %d", p.drmDisplayCount)
|
||||
p.displayStatus, err = NewStringItem(displayStatusText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create display status texture: %w", err)
|
||||
}
|
||||
|
||||
displayStatusScale := float32(24) / float32(baseFontSize)
|
||||
p.displayStatus.Scale(displayStatusScale)
|
||||
|
||||
// Update SDL count
|
||||
p.sdlDisplayCount = countSDLDisplays()
|
||||
|
||||
// Delete old texture
|
||||
if p.sdlDisplayStatus.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.sdlDisplayStatus.ID)
|
||||
}
|
||||
|
||||
// Create new texture
|
||||
sdlDisplayStatusText := fmt.Sprintf("SDL Displays Detected: %d", p.sdlDisplayCount)
|
||||
p.sdlDisplayStatus, err = NewStringItem(sdlDisplayStatusText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create SDL display status texture: %w", err)
|
||||
}
|
||||
|
||||
p.sdlDisplayStatus.Scale(displayStatusScale)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *Welcome) Init(app ApplicationInterface) error {
|
||||
|
||||
title := "Rhema 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(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("Roboto-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch 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)
|
||||
}
|
||||
|
||||
// Initialize display counts
|
||||
p.lastDisplayUpdate = time.Now()
|
||||
if err := p.updateDisplayCounts(); err != nil {
|
||||
return fmt.Errorf("unable to update display counts: %w", err)
|
||||
}
|
||||
|
||||
// Load license information from database
|
||||
emailAddress, err := db.GetConfigValue("email_address")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving email_address from db: %w", err)
|
||||
}
|
||||
|
||||
licenseKey, err := db.GetConfigValue("license_key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving license_key from db: %w", err)
|
||||
}
|
||||
|
||||
// Create license info textures if data exists
|
||||
if emailAddress != "" || 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 emailAddress != "" {
|
||||
emailText := fmt.Sprintf("Email: %s", 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 licenseKey != "" {
|
||||
licenseKeyText := fmt.Sprintf("License Key: %s", 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
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
p.baseFontSize = baseFontSize
|
||||
|
||||
// Scale the note text
|
||||
fontSize := int32(32)
|
||||
scale := float32(fontSize) / float32(baseFontSize)
|
||||
p.note.Scale(scale * 0.75)
|
||||
|
||||
// Scale license info texts
|
||||
if p.hasLicenseInfo {
|
||||
licenseInfoScale := float32(28) / float32(baseFontSize)
|
||||
p.licenseInfoHeading.Scale(licenseInfoScale)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.emailDisplay.Scale(licenseInfoScale * 0.85)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.licenseKeyDisplay.Scale(licenseInfoScale * 0.85)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the Welcome page.
|
||||
func (p *Welcome) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||
func (p *Welcome) Update(dt float32) error {
|
||||
// Update display counts every 2 seconds
|
||||
if time.Since(p.lastDisplayUpdate) >= 2*time.Second {
|
||||
if err := p.updateDisplayCounts(); err != nil {
|
||||
}
|
||||
p.lastDisplayUpdate = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Welcome) Render() error {
|
||||
baseFontSize := p.baseFontSize
|
||||
sm := shaderManager.Get()
|
||||
verticalMargin := int32(20)
|
||||
|
||||
// 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}
|
||||
|
||||
// p.note already scaled in Init via p.note.Scale(scale*0.75)
|
||||
noteDim := p.note.RawDimensions()
|
||||
noteW, noteH := noteDim[0], noteDim[1]
|
||||
|
||||
// 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) // Title sits at the top
|
||||
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 display status in bottom left corner
|
||||
displayStatusDim := p.displayStatus.RawDimensions()
|
||||
displayStatusH := displayStatusDim[1]
|
||||
|
||||
// sdlDisplayStatusDim := p.sdlDisplayStatus.RawDimensions()
|
||||
// sdlDisplayStatusH := sdlDisplayStatusDim[1]
|
||||
|
||||
displayStatusMargin := int32(5)
|
||||
|
||||
p.displayStatus.X = 15
|
||||
p.displayStatus.Y = 15
|
||||
|
||||
p.sdlDisplayStatus.X = 15
|
||||
p.sdlDisplayStatus.Y = 15 + int32(displayStatusH) + displayStatusMargin
|
||||
|
||||
// 7) Position license info at bottom center
|
||||
if p.hasLicenseInfo {
|
||||
licenseInfoMargin := int32(10)
|
||||
bottomMargin := int32(20)
|
||||
|
||||
// License info items are already scaled in Init
|
||||
licenseHeadingDim := p.licenseInfoHeading.RawDimensions()
|
||||
licenseHeadingW, licenseHeadingH := licenseHeadingDim[0], licenseHeadingDim[1]
|
||||
|
||||
var emailDisplayH, licenseKeyDisplayH float32
|
||||
if p.emailDisplay.ID != 0 {
|
||||
emailDisplayDim := p.emailDisplay.RawDimensions()
|
||||
emailDisplayH = emailDisplayDim[1]
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
|
||||
licenseKeyDisplayH = licenseKeyDisplayDim[1]
|
||||
}
|
||||
|
||||
// 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 {
|
||||
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
|
||||
licenseKeyDisplayW := licenseKeyDisplayDim[0]
|
||||
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
|
||||
p.licenseKeyDisplay.Y = currentY
|
||||
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Email
|
||||
if p.emailDisplay.ID != 0 {
|
||||
emailDisplayDim := p.emailDisplay.RawDimensions()
|
||||
emailDisplayW := emailDisplayDim[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
|
||||
}
|
||||
|
||||
// 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, p.prompt.Position(), promptDim, color)
|
||||
p.Base.RenderTexture(textShader, p.note.ID, p.note.Position(), p.note.RawDimensions(), color)
|
||||
|
||||
// Render display status
|
||||
p.Base.RenderTexture(textShader, p.displayStatus.ID, p.displayStatus.Position(), p.displayStatus.RawDimensions(), color)
|
||||
p.Base.RenderTexture(textShader, p.sdlDisplayStatus.ID, p.sdlDisplayStatus.Position(), p.sdlDisplayStatus.RawDimensions(), color)
|
||||
|
||||
// Render license info if it exists
|
||||
if p.hasLicenseInfo {
|
||||
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, p.licenseInfoHeading.Position(), p.licenseInfoHeading.RawDimensions(), color)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.emailDisplay.ID, p.emailDisplay.Position(), p.emailDisplay.RawDimensions(), color)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, p.licenseKeyDisplay.Position(), p.licenseKeyDisplay.RawDimensions(), color)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *Welcome) 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.displayStatus.ID)
|
||||
gl.DeleteTextures(1, &p.sdlDisplayStatus.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,790 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"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/network"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// accessPointUIItem holds both the network data and its rendered texture.
|
||||
type accessPointUIItem struct {
|
||||
AP network.AccessPoint
|
||||
Item StringItem
|
||||
}
|
||||
|
||||
// WiFiSetupPage manages the discovery and connection to Wi-Fi networks.
|
||||
type WiFiSetupPage struct {
|
||||
Base
|
||||
networkManager *network.Manager
|
||||
applicationFont *fontManager.FontEntry
|
||||
|
||||
// Page-specific UI elements
|
||||
titleItem StringItem
|
||||
scanningStatusItem StringItem
|
||||
connectionStatusItem StringItem
|
||||
passwordPromptItem StringItem
|
||||
passwordInputItem StringItem
|
||||
passwordErrorItem StringItem
|
||||
|
||||
// Network list and selection
|
||||
accessPoints []accessPointUIItem
|
||||
selectedIndex int
|
||||
scrollOffset int
|
||||
|
||||
// State management
|
||||
isScanning bool
|
||||
isEnteringPassword bool
|
||||
isConnecting bool
|
||||
connectionError string
|
||||
passwordInput string
|
||||
selectedAP *network.AccessPoint
|
||||
wifiDevice *network.Device
|
||||
connectionStatus *network.ConnectionStatus
|
||||
activeSSID string // Tracks the CONFIRMED active network
|
||||
pendingSSID string // Tracks the network we are TRYING to connect to
|
||||
connectionAttemptTime time.Time
|
||||
connectionTimeout time.Duration
|
||||
|
||||
// Text input deduplication
|
||||
textInputSupported bool
|
||||
|
||||
// Async management
|
||||
resultsChan chan []network.AccessPoint
|
||||
statusChan chan *network.ConnectionStatus
|
||||
errorChan chan error
|
||||
stopScan chan struct{}
|
||||
}
|
||||
|
||||
// Init sets up the page, initializes the network manager, and starts a scan.
|
||||
func (p *WiFiSetupPage) Init(app ApplicationInterface) error {
|
||||
|
||||
if err := p.Base.Init(app); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Get application font from database
|
||||
applicationFontName, err := db.GetConfigValue("application_font")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||
}
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont(applicationFontName)
|
||||
if !ok {
|
||||
return fmt.Errorf("could not find application font: %s", applicationFontName)
|
||||
}
|
||||
p.applicationFont = font
|
||||
|
||||
// Initialize network manager
|
||||
nm, err := network.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize network manager: %w", err)
|
||||
}
|
||||
p.networkManager = nm
|
||||
|
||||
// Find Wi-Fi device
|
||||
if err := p.networkManager.FindDevices(); err != nil {
|
||||
}
|
||||
for _, dev := range p.networkManager.Devices {
|
||||
if dev.Type == network.TypeWifi {
|
||||
d := dev
|
||||
p.wifiDevice = &d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create UI elements
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, white)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title item: %w", err)
|
||||
}
|
||||
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, white)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create scanning status item: %w", err)
|
||||
}
|
||||
|
||||
// Initialize channels and timeout
|
||||
p.resultsChan = make(chan []network.AccessPoint)
|
||||
p.statusChan = make(chan *network.ConnectionStatus, 1)
|
||||
p.errorChan = make(chan error)
|
||||
p.stopScan = make(chan struct{})
|
||||
p.connectionTimeout = 15 * time.Second
|
||||
|
||||
// Start scanning and status monitoring
|
||||
p.startScan()
|
||||
go p.checkConnectionStatusLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkConnectionStatusLoop periodically checks the network status.
|
||||
func (p *WiFiSetupPage) checkConnectionStatusLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second) // Check more frequently for better UX
|
||||
defer ticker.Stop()
|
||||
|
||||
status, _ := p.networkManager.CheckInternetConnection()
|
||||
p.statusChan <- status
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
status, _ := p.networkManager.CheckInternetConnection()
|
||||
p.statusChan <- status
|
||||
case <-p.stopScan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startScan triggers a new asynchronous network scan.
|
||||
func (p *WiFiSetupPage) startScan() {
|
||||
p.isScanning = true
|
||||
p.selectedIndex = 0
|
||||
p.scrollOffset = 0
|
||||
p.accessPoints = nil
|
||||
|
||||
if p.wifiDevice != nil {
|
||||
go p.networkManager.StartScan(p.resultsChan, p.errorChan, p.stopScan)
|
||||
} else {
|
||||
p.isScanning = false
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEvent processes user input.
|
||||
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
|
||||
if p.isEnteringPassword && !sdl.IsTextInputActive() {
|
||||
p.enterPasswordMode()
|
||||
}
|
||||
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.TextInputEvent:
|
||||
if p.isEnteringPassword {
|
||||
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.passwordInput += e.GetText()
|
||||
p.updatePasswordTexture()
|
||||
}
|
||||
}
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
if p.isEnteringPassword {
|
||||
p.handlePasswordInput(e.Keysym.Sym, e.Keysym.Mod)
|
||||
} else {
|
||||
p.handleNetworkSelection(e.Keysym.Sym)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePasswordInput manages keyboard events during password entry.
|
||||
func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) {
|
||||
switch sym {
|
||||
case sdl.K_RETURN:
|
||||
p.exitPasswordMode()
|
||||
p.pendingSSID = p.selectedAP.SSID
|
||||
p.isConnecting = true
|
||||
p.connectionError = ""
|
||||
p.connectionAttemptTime = time.Now()
|
||||
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput)
|
||||
if err != nil {
|
||||
p.isConnecting = false
|
||||
p.pendingSSID = ""
|
||||
p.connectionError = "Failed to initiate connection"
|
||||
}
|
||||
p.isEnteringPassword = false
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.passwordInput) > 0 {
|
||||
p.passwordInput = p.passwordInput[:len(p.passwordInput)-1]
|
||||
p.updatePasswordTexture()
|
||||
}
|
||||
case sdl.K_ESCAPE:
|
||||
p.isEnteringPassword = false
|
||||
p.pendingSSID = ""
|
||||
p.connectionError = ""
|
||||
p.exitPasswordMode()
|
||||
default:
|
||||
// Keyboard fallback: if TextInputEvent was not received for this
|
||||
// event cycle, convert printable keycodes to characters directly.
|
||||
// This handles kiosk systems without an input method framework.
|
||||
if !p.textInputSupported {
|
||||
if ch := keycodeToChar(sym, mod); ch != 0 {
|
||||
p.passwordInput += string(ch)
|
||||
p.updatePasswordTexture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enterPasswordMode disables global keybinds and starts SDL text input.
|
||||
func (p *WiFiSetupPage) enterPasswordMode() {
|
||||
keybinds.Enabled = false
|
||||
sdl.StartTextInput()
|
||||
}
|
||||
|
||||
// exitPasswordMode re-enables global keybinds and stops SDL text input.
|
||||
func (p *WiFiSetupPage) exitPasswordMode() {
|
||||
keybinds.Enabled = true
|
||||
sdl.StopTextInput()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// handleNetworkSelection manages keyboard events for the network list.
|
||||
func (p *WiFiSetupPage) handleNetworkSelection(sym sdl.Keycode) {
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.selectedIndex > 0 {
|
||||
p.selectedIndex--
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.selectedIndex < len(p.accessPoints)-1 {
|
||||
p.selectedIndex++
|
||||
}
|
||||
case sdl.K_RETURN:
|
||||
if len(p.accessPoints) > 0 && p.selectedIndex < len(p.accessPoints) {
|
||||
p.selectedAP = &p.accessPoints[p.selectedIndex].AP
|
||||
p.pendingSSID = p.selectedAP.SSID
|
||||
p.connectionError = ""
|
||||
if p.selectedAP.IsProtected {
|
||||
p.isEnteringPassword = true
|
||||
p.passwordInput = ""
|
||||
p.updatePasswordTexture()
|
||||
p.updatePasswordPromptTexture()
|
||||
p.enterPasswordMode()
|
||||
} else {
|
||||
p.isConnecting = true
|
||||
p.connectionAttemptTime = time.Now()
|
||||
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, "")
|
||||
if err != nil {
|
||||
p.isConnecting = false
|
||||
p.pendingSSID = ""
|
||||
p.connectionError = "Failed to initiate connection"
|
||||
}
|
||||
}
|
||||
}
|
||||
case sdl.K_r:
|
||||
if !p.isScanning {
|
||||
p.startScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update checks for async results and updates UI state.
|
||||
func (p *WiFiSetupPage) Update(dt float32) error {
|
||||
select {
|
||||
case results := <-p.resultsChan:
|
||||
p.isScanning = false
|
||||
p.buildNetworkUIList(results)
|
||||
case status := <-p.statusChan:
|
||||
p.connectionStatus = status
|
||||
p.handleConnectionStatusUpdate(status)
|
||||
p.updateConnectionStatusItem()
|
||||
case err := <-p.errorChan:
|
||||
p.isScanning = false
|
||||
fmt.Printf("error scannign for networks %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// Check for connection timeout
|
||||
if p.isConnecting && time.Since(p.connectionAttemptTime) > p.connectionTimeout {
|
||||
p.isConnecting = false
|
||||
p.connectionError = "Connection timed out - Incorrect password?"
|
||||
if p.selectedAP != nil && p.selectedAP.IsProtected {
|
||||
p.isEnteringPassword = true
|
||||
p.passwordInput = ""
|
||||
p.updatePasswordTexture()
|
||||
p.updatePasswordPromptTexture()
|
||||
p.updatePasswordErrorTexture()
|
||||
p.enterPasswordMode()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConnectionStatusUpdate checks if connection succeeded or failed
|
||||
func (p *WiFiSetupPage) handleConnectionStatusUpdate(status *network.ConnectionStatus) {
|
||||
if !p.isConnecting {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we successfully connected to our pending SSID
|
||||
switch status.StatusCode {
|
||||
case network.StatusWifiConnectedInternetUp:
|
||||
activeSSID := p.getActiveSSID()
|
||||
if activeSSID == p.pendingSSID {
|
||||
p.isConnecting = false
|
||||
p.pendingSSID = ""
|
||||
p.connectionError = ""
|
||||
p.refreshNetworkColors()
|
||||
}
|
||||
case network.StatusWifiAvailableNotConnected:
|
||||
// Connection failed - we're trying to connect but now show as not connected
|
||||
if time.Since(p.connectionAttemptTime) > 5*time.Second {
|
||||
p.isConnecting = false
|
||||
if p.selectedAP != nil && p.selectedAP.IsProtected {
|
||||
p.connectionError = "Connection failed - Incorrect password?"
|
||||
p.isEnteringPassword = true
|
||||
p.passwordInput = ""
|
||||
p.updatePasswordTexture()
|
||||
p.updatePasswordPromptTexture()
|
||||
p.updatePasswordErrorTexture()
|
||||
p.enterPasswordMode()
|
||||
} else {
|
||||
p.connectionError = "Connection failed"
|
||||
p.pendingSSID = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getActiveSSID queries NetworkManager for the currently active WiFi SSID
|
||||
func (p *WiFiSetupPage) getActiveSSID() string {
|
||||
if p.networkManager == nil || p.wifiDevice == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
nmObj := p.networkManager.Conn().Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
|
||||
prop, err := nmObj.GetProperty("org.freedesktop.NetworkManager.ActiveConnections")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if paths, ok := prop.Value().([]dbus.ObjectPath); ok {
|
||||
for _, path := range paths {
|
||||
acObj := p.networkManager.Conn().Object("org.freedesktop.NetworkManager", path)
|
||||
devProp, err := acObj.GetProperty("org.freedesktop.NetworkManager.Connection.Active.Devices")
|
||||
if err == nil {
|
||||
if devPaths, ok := devProp.Value().([]dbus.ObjectPath); ok {
|
||||
for _, devPath := range devPaths {
|
||||
if string(devPath) == string(p.wifiDevice.Path) {
|
||||
idProp, err := acObj.GetProperty("org.freedesktop.NetworkManager.Connection.Active.Id")
|
||||
if err == nil {
|
||||
if id, ok := idProp.Value().(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// refreshNetworkColors updates the colors of network items based on active status
|
||||
func (p *WiFiSetupPage) refreshNetworkColors() {
|
||||
activeSSID := p.getActiveSSID()
|
||||
p.activeSSID = activeSSID
|
||||
|
||||
for i := range p.accessPoints {
|
||||
if p.accessPoints[i].AP.SSID == activeSSID {
|
||||
p.accessPoints[i].Item.Color = colors.LawnGreen
|
||||
} else {
|
||||
p.accessPoints[i].Item.Color = colors.White
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateConnectionStatusItem creates the colored status message item.
|
||||
func (p *WiFiSetupPage) updateConnectionStatusItem() {
|
||||
if p.connectionStatusItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
||||
}
|
||||
|
||||
// Show connecting message if in progress
|
||||
if p.isConnecting {
|
||||
message := fmt.Sprintf("Connecting to %s...", p.pendingSSID)
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.Yellow)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show connection error if present
|
||||
if p.connectionError != "" {
|
||||
item, err := NewStringItem(p.connectionError, p.applicationFont, colors.Red)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p.connectionStatus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch p.connectionStatus.StatusCode {
|
||||
case network.StatusWifiConnectedInternetUp:
|
||||
message := fmt.Sprintf("Connected to %s - Internet accessible | F1 to exit", p.activeSSID)
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
p.refreshNetworkColors()
|
||||
|
||||
case network.StatusWifiAvailableNotConnected:
|
||||
message := "WiFi available - Not connected | Select network or press R to rescan"
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.Yellow)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
|
||||
case network.StatusEthernetConnectedInternetUp:
|
||||
message := "Ethernet connected - Internet accessible | F1 to exit"
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
|
||||
case network.StatusEthernetNotConnected:
|
||||
message := "No network connection | Select WiFi network or press R to rescan"
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
|
||||
case network.StatusNoDevicesFound:
|
||||
message := "No network devices found"
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
|
||||
case network.StatusError:
|
||||
message := "Network status check failed"
|
||||
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
|
||||
default:
|
||||
item, err := NewStringItem(p.connectionStatus.Message, p.applicationFont, colors.White)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildNetworkUIList creates the renderable textures for the list of discovered networks.
|
||||
func (p *WiFiSetupPage) buildNetworkUIList(aps []network.AccessPoint) {
|
||||
// Get the currently active Wi-Fi SSID
|
||||
activeSSID := p.getActiveSSID()
|
||||
p.activeSSID = activeSSID
|
||||
|
||||
// Set IsActive flag based on the actual active SSID
|
||||
for i := range aps {
|
||||
aps[i].IsActive = (aps[i].SSID == p.activeSSID)
|
||||
}
|
||||
|
||||
p.accessPoints = make([]accessPointUIItem, len(aps))
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
|
||||
for i, ap := range aps {
|
||||
ssid := ap.SSID
|
||||
if ap.IsProtected {
|
||||
ssid += " [L]"
|
||||
}
|
||||
color := white
|
||||
if ap.IsActive {
|
||||
color = colors.LawnGreen
|
||||
}
|
||||
item, err := NewStringItem(ssid, p.applicationFont, color)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p.accessPoints[i] = accessPointUIItem{AP: ap, Item: item}
|
||||
}
|
||||
}
|
||||
|
||||
// updatePasswordTexture regenerates the texture for the password input field.
|
||||
func (p *WiFiSetupPage) updatePasswordTexture() {
|
||||
if p.passwordInputItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
||||
}
|
||||
maskedPassword := strings.Repeat("*", len(p.passwordInput))
|
||||
if maskedPassword == "" {
|
||||
maskedPassword = " "
|
||||
}
|
||||
item, err := NewStringItem(maskedPassword, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||
if err == nil {
|
||||
p.passwordInputItem = item
|
||||
}
|
||||
}
|
||||
|
||||
// updatePasswordPromptTexture creates the texture for the password prompt label.
|
||||
func (p *WiFiSetupPage) updatePasswordPromptTexture() {
|
||||
if p.passwordPromptItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
||||
}
|
||||
prompt := fmt.Sprintf("Password for %s:", p.selectedAP.SSID)
|
||||
item, err := NewStringItem(prompt, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||
if err == nil {
|
||||
p.passwordPromptItem = item
|
||||
}
|
||||
}
|
||||
|
||||
// updatePasswordErrorTexture creates the texture for password error message.
|
||||
func (p *WiFiSetupPage) updatePasswordErrorTexture() {
|
||||
if p.passwordErrorItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
||||
}
|
||||
if p.connectionError != "" {
|
||||
item, err := NewStringItem(p.connectionError, p.applicationFont, colors.DarkRed)
|
||||
if err == nil {
|
||||
p.passwordErrorItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render draws the entire page.
|
||||
func (p *WiFiSetupPage) Render() error {
|
||||
// Get base font size from database
|
||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||
}
|
||||
baseFontSize, err := strconv.ParseFloat(baseFontSizeStr, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||
}
|
||||
|
||||
// Colors & Config
|
||||
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
||||
selectionColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
verticalMargin := float32(20)
|
||||
|
||||
solidShader, _ := shaderManager.Get().Get("solid_color")
|
||||
textShader, _ := shaderManager.Get().Get("text")
|
||||
|
||||
// Background
|
||||
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{0, 0}, mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, bgColor)
|
||||
|
||||
// Title
|
||||
titleScale := float32(48) / float32(baseFontSize)
|
||||
titleW := float32(p.titleItem.W) * titleScale
|
||||
titleH := float32(p.titleItem.H) * titleScale
|
||||
titleX := float32(p.ScreenCenterX) - (titleW / 2)
|
||||
titleY := float32(p.ScreenHeight) - titleH - 40
|
||||
p.Base.RenderTexture(textShader, p.titleItem.ID, mgl32.Vec2{titleX, titleY}, mgl32.Vec2{titleW, titleH}, white)
|
||||
|
||||
// Status message
|
||||
statusY := titleY - titleH - verticalMargin
|
||||
if p.isScanning {
|
||||
statusScale := float32(24) / float32(baseFontSize)
|
||||
statusW := float32(p.scanningStatusItem.W) * statusScale
|
||||
statusH := float32(p.scanningStatusItem.H) * statusScale
|
||||
statusX := float32(p.ScreenCenterX) - (statusW / 2)
|
||||
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, white)
|
||||
} else if p.connectionStatusItem.ID != 0 {
|
||||
statusScale := float32(24) / float32(baseFontSize)
|
||||
statusW := float32(p.connectionStatusItem.W) * statusScale
|
||||
statusH := float32(p.connectionStatusItem.H) * statusScale
|
||||
statusX := float32(p.ScreenCenterX) - (statusW / 2)
|
||||
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, p.connectionStatusItem.Color)
|
||||
}
|
||||
|
||||
// Network list
|
||||
listY := statusY - verticalMargin
|
||||
if len(p.accessPoints) > 0 {
|
||||
itemHeight := int32(40)
|
||||
listPadding := int32(10)
|
||||
for i, apItem := range p.accessPoints {
|
||||
itemYpos := listY - float32(int32(i+1)*itemHeight)
|
||||
itemXpos := float32(p.ScreenCenterX - 350)
|
||||
itemW := float32(700)
|
||||
if i == p.selectedIndex {
|
||||
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{itemXpos, itemYpos}, mgl32.Vec2{itemW, float32(itemHeight)}, selectionColor)
|
||||
}
|
||||
|
||||
textScale := float32(24) / float32(baseFontSize)
|
||||
textW := float32(apItem.Item.W) * textScale
|
||||
textH := float32(apItem.Item.H) * textScale
|
||||
textY := itemYpos + (float32(itemHeight) / 2) - (textH / 2)
|
||||
textX := itemXpos + float32(listPadding)
|
||||
p.Base.RenderTexture(textShader, apItem.Item.ID, mgl32.Vec2{textX, textY}, mgl32.Vec2{textW, textH}, apItem.Item.Color)
|
||||
}
|
||||
}
|
||||
|
||||
// Password dialog overlay
|
||||
if p.isEnteringPassword {
|
||||
if err := p.renderPasswordDialog(solidShader, textShader, float32(baseFontSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderPasswordDialog draws the password entry modal.
|
||||
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
|
||||
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 150}
|
||||
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 := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
|
||||
dialogW, dialogH := int32(600), int32(250)
|
||||
if p.connectionError != "" {
|
||||
dialogH = int32(300) // Make dialog taller if there's an error
|
||||
}
|
||||
dialogX, dialogY := p.ScreenCenterX-(dialogW/2), p.ScreenCenterY-(dialogH/2)
|
||||
|
||||
// Overlay and dialog background
|
||||
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
|
||||
|
||||
// Password error (if exists) - shown at the top in red
|
||||
if p.passwordErrorItem.ID != 0 {
|
||||
errorScale := float32(20) / baseFontSize
|
||||
errorW := float32(p.passwordErrorItem.W) * errorScale
|
||||
errorH := float32(p.passwordErrorItem.H) * errorScale
|
||||
errorX := float32(p.ScreenCenterX) - (errorW / 2)
|
||||
currentY -= errorH
|
||||
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID, mgl32.Vec2{errorX, currentY}, mgl32.Vec2{errorW, errorH}, colors.DarkRed)
|
||||
currentY -= 20
|
||||
}
|
||||
|
||||
// Password prompt
|
||||
if p.passwordPromptItem.ID != 0 {
|
||||
promptScale := float32(24) / baseFontSize
|
||||
promptW := float32(p.passwordPromptItem.W) * promptScale
|
||||
promptH := float32(p.passwordPromptItem.H) * promptScale
|
||||
promptX := float32(p.ScreenCenterX) - (promptW / 2)
|
||||
currentY -= promptH
|
||||
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID, mgl32.Vec2{promptX, currentY}, mgl32.Vec2{promptW, promptH}, white)
|
||||
currentY -= 30
|
||||
}
|
||||
|
||||
// Input box
|
||||
padding, border := int32(4), int32(3)
|
||||
boxW := int32(500)
|
||||
targetFontSize := int32(36)
|
||||
boxH := targetFontSize + (padding * 2)
|
||||
borderBoxW, borderBoxH := boxW+(border*2), boxH+(border*2)
|
||||
borderBoxX := p.ScreenCenterX - (borderBoxW / 2)
|
||||
borderBoxY := int32(currentY) - boxH - (padding * 2)
|
||||
boxX, boxY := p.ScreenCenterX-(boxW/2), int32(currentY)-boxH-padding
|
||||
|
||||
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}, mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}, boxBorderColor)
|
||||
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(boxX), float32(boxY)}, mgl32.Vec2{float32(boxW), float32(boxH)}, boxBgColor)
|
||||
|
||||
// Password input text
|
||||
if p.passwordInputItem.ID != 0 {
|
||||
inputScale := float32(targetFontSize) / baseFontSize
|
||||
inputW := float32(p.passwordInputItem.W) * inputScale
|
||||
inputH := float32(p.passwordInputItem.H) * inputScale
|
||||
inputX := float32(boxX + padding)
|
||||
inputY := float32(boxY + padding)
|
||||
p.Base.RenderTexture(textShader, p.passwordInputItem.ID, mgl32.Vec2{inputX, inputY}, mgl32.Vec2{inputW, inputH}, white)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up all resources used by the page.
|
||||
func (p *WiFiSetupPage) Destroy() error {
|
||||
// Re-enable global keybinds in case we're destroyed during password entry.
|
||||
keybinds.Enabled = true
|
||||
|
||||
if p.stopScan != nil {
|
||||
close(p.stopScan)
|
||||
p.stopScan = nil
|
||||
}
|
||||
|
||||
p.networkManager.Close()
|
||||
|
||||
gl.DeleteTextures(1, &p.titleItem.ID)
|
||||
gl.DeleteTextures(1, &p.scanningStatusItem.ID)
|
||||
if p.connectionStatusItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
||||
}
|
||||
if p.passwordPromptItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
||||
}
|
||||
if p.passwordInputItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
||||
}
|
||||
if p.passwordErrorItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
||||
}
|
||||
|
||||
for _, apItem := range p.accessPoints {
|
||||
if apItem.Item.ID != 0 {
|
||||
gl.DeleteTextures(1, &apItem.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
Reference in New Issue
Block a user