Initial release: RadiantWave v1.0

This commit is contained in:
NeilClack
2026-03-12 08:05:10 -05:00
commit 403ec4db89
555 changed files with 437856 additions and 0 deletions
+694
View File
@@ -0,0 +1,694 @@
package application
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/graphics"
"rhemawavetech.com/rhemawave/internal/keybinds"
"rhemawavetech.com/rhemawave/internal/license"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/network"
"rhemawavetech.com/rhemawave/internal/page"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
const (
targetFPS = 60.0
volumeStepSize = 16
openGLMajorVersion = 3
openGLMinorVersion = 3
apiURL = "http://localhost:8000"
apiPath = "/api/v1/licenseKey/validate"
audioFileURL = "https://files.neilclack.com/audio.wav"
)
type Application struct {
Window *sdl.Window
License *license.Response
pageStack []page.Page
pendingAction func()
running bool
}
// ----------------------------------------------------------------------------
// Main Entry Point
// ----------------------------------------------------------------------------
func Run() {
logger.Info("Starting application")
appDataDir, err := initializeApplicationDirectory()
if err != nil {
logger.Fatal("Failed to initialize application directory: %v", err)
}
logger.Info("App directory: %s", appDataDir)
logger.AddFile(filepath.Join(appDataDir, "log.txt"))
if err := initializeDatabase(appDataDir); err != nil {
logger.Fatal("Database initialization failed: %v", err)
}
app := &Application{}
app.initializeSDLAndOpenGL()
defer app.cleanup()
app.initializeManagers()
app.initializeAudio()
app.validateLicenseKey()
app.startupPageStack()
app.runEventLoop()
}
// ----------------------------------------------------------------------------
// Initialization Functions
// ----------------------------------------------------------------------------
func initializeApplicationDirectory() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
appDataDir := filepath.Join(homeDir, ".local", "share", "rhemawave")
if err := os.MkdirAll(appDataDir, 0755); err != nil {
return "", fmt.Errorf("could not create application data directory %s: %w", appDataDir, err)
}
testFile := filepath.Join(appDataDir, ".access_test")
f, err := os.Create(testFile)
if err != nil {
return "", fmt.Errorf("application data directory %s is not writable: %w", appDataDir, err)
}
f.Close()
os.Remove(testFile)
return appDataDir, nil
}
func initializeDatabase(appDataDir string) error {
dbPath := filepath.Join(appDataDir, "data.db")
if err := db.InitDatabase(dbPath); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
logger.AddDatabase(db.DB)
return nil
}
type progressWriter struct {
w io.Writer
written *atomic.Int64
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.w.Write(p)
pw.written.Add(int64(n))
return n, err
}
// ensureAudioFile checks whether the selected audio file exists. If it does,
// it returns nil. Otherwise it starts a background download and returns a
// DownloadProgressPage that tracks the download so it can be pushed onto the
// page stack.
func ensureAudioFile() *page.DownloadProgressPage {
assetsDir, err := db.GetConfigValue("assets_dir")
if err != nil {
logger.Error("Failed to get assets_dir: %v", err)
return nil
}
selectedAudio, err := db.GetConfigValue("selected_audio")
if err != nil || selectedAudio == "" {
logger.Warning("No selected_audio configured")
return nil
}
audioPath := filepath.Join(assetsDir, selectedAudio)
if _, err := os.Stat(audioPath); err == nil {
logger.Info("Audio file found: %s", audioPath)
return nil
}
logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL)
totalBytes := &atomic.Int64{}
bytesWritten := &atomic.Int64{}
done := &atomic.Bool{}
hasError := &atomic.Bool{}
downloadPage := &page.DownloadProgressPage{
TotalBytes: totalBytes,
BytesWritten: bytesWritten,
Done: done,
HasError: hasError,
}
go func() {
defer done.Store(true)
if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil {
logger.Error("Failed to create audio directory: %v", err)
hasError.Store(true)
return
}
resp, err := http.Get(audioFileURL)
if err != nil {
logger.Error("Failed to download audio file: %v", err)
hasError.Store(true)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Error("Audio download returned status %d", resp.StatusCode)
hasError.Store(true)
return
}
if resp.ContentLength > 0 {
totalBytes.Store(resp.ContentLength)
}
tmp := audioPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
logger.Error("Failed to create audio file: %v", err)
hasError.Store(true)
return
}
pw := &progressWriter{w: f, written: bytesWritten}
_, err = io.Copy(pw, resp.Body)
f.Close()
if err != nil {
os.Remove(tmp)
logger.Error("Failed to write audio file: %v", err)
hasError.Store(true)
return
}
if err := os.Rename(tmp, audioPath); err != nil {
os.Remove(tmp)
logger.Error("Failed to move audio file into place: %v", err)
hasError.Store(true)
return
}
logger.Info("Audio file downloaded successfully (%d bytes)", bytesWritten.Load())
}()
return downloadPage
}
func (app *Application) initializeSDLAndOpenGL() {
logger.Info("Initializing SDL")
if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil {
logger.Fatal("SDL init failed: %v", err)
}
logger.Info("Initializing TTF")
if err := ttf.Init(); err != nil {
logger.Fatal("TTF init failed: %v", err)
}
sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion)
sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion)
sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
sdl.ShowCursor(sdl.DISABLE)
sdl.SetRelativeMouseMode(true)
logger.Info("Querying display mode")
displayMode, err := sdl.GetDisplayMode(0, 0)
if err != nil {
logger.Fatal("Display mode query failed: %v", err)
}
if displayMode.W == 0 || displayMode.H == 0 {
logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H)
}
logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate)
logger.Info("Creating window")
window, err := sdl.CreateWindow(
"Rhema Wave",
sdl.WINDOWPOS_UNDEFINED,
sdl.WINDOWPOS_UNDEFINED,
displayMode.W,
displayMode.H,
sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN,
)
if err != nil {
logger.Fatal("Window creation failed: %v", err)
}
app.Window = window
logger.Info("Creating OpenGL context")
if _, err := app.Window.GLCreateContext(); err != nil {
logger.Fatal("OpenGL context creation failed: %v", err)
}
logger.Info("Initializing go-gl")
if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil {
logger.Fatal("go-gl initialization failed: %v", err)
}
logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION)))
logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER)))
if err := sdl.GLSetSwapInterval(1); err != nil {
logger.Warning("Failed to enable vsync: %v", err)
}
w, h := app.Window.GLGetDrawableSize()
logger.Info("Drawable size: %dx%d", w, h)
logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)",
graphics.VirtualWidth, graphics.VirtualHeight, w, h)
if err := graphics.InitVirtualFramebuffer(w, h); err != nil {
logger.Fatal("Virtual framebuffer init failed: %v", err)
}
app.initKeybinds()
logger.Info("SDL and OpenGL initialization complete")
}
func (app *Application) initializeManagers() {
logger.Info("Initializing ShaderManager")
if err := shaderManager.InitShaderManager(); err != nil {
logger.Fatal("ShaderManager initialization failed: %v", err)
}
logger.Info("Initializing FontManager")
if err := fontManager.InitFontManager(); err != nil {
logger.Fatal("FontManager initialization failed: %v", err)
}
}
func (app *Application) initializeAudio() {
logger.Info("Initializing audio")
audioDevice, err := db.GetConfigValue("audio_device_name")
if err != nil {
logger.Warning("Failed to get audio device name from config: %v", err)
}
if err := mixer.Init(audioDevice); err != nil {
logger.Error("Unable to initialize audio device '%s': %v", audioDevice, err)
logger.Error("Audio will be unavailable until a device is configured")
} else {
logger.Info("Audio initialized with device: '%s'", audioDevice)
}
}
func (app *Application) cleanup() {
logger.Info("Cleaning up")
for _, p := range app.pageStack {
p.Destroy()
}
app.pageStack = nil
graphics.DestroyVirtualFramebuffer()
if app.Window != nil {
app.Window.Destroy()
}
ttf.Quit()
sdl.Quit()
logger.Info("Cleanup complete")
logger.Close()
}
// ----------------------------------------------------------------------------
// Event Loop
// ----------------------------------------------------------------------------
func (app *Application) runEventLoop() {
if len(app.pageStack) == 0 {
logger.Fatal("No initial page loaded, pageStack is empty")
}
const frameDelay = time.Second / targetFPS
app.running = true
lastTime := time.Now()
logger.Info("Entering event loop")
for app.running {
frameStart := time.Now()
app.handleEvents()
if !app.running {
break
}
deltaTime := float32(time.Since(lastTime).Seconds())
lastTime = time.Now()
app.updateCurrentPage(deltaTime)
app.renderFrame()
app.applyPendingAction()
frameTime := time.Since(frameStart)
if frameTime < frameDelay {
time.Sleep(frameDelay - frameTime)
}
}
logger.Info("Event loop exited")
}
func (app *Application) handleEvents() {
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
switch e := event.(type) {
case *sdl.QuitEvent:
app.running = false
return
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN && e.Repeat == 0 {
isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0
if e.Keysym.Sym == sdl.K_c && isCtrlPressed {
continue
}
keybinds.PerformAction(e)
}
}
if currentPage := app.currentPage(); currentPage != nil {
if err := currentPage.HandleEvent(&event); err != nil {
logger.Error("Page event handler error: %v", err)
}
}
}
}
func (app *Application) updateCurrentPage(deltaTime float32) {
if currentPage := app.currentPage(); currentPage != nil {
currentPage.Update(deltaTime)
}
}
func (app *Application) renderFrame() {
graphics.BeginVirtualRender()
gl.ClearColor(0.0, 0.0, 0.0, 1.0)
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
if currentPage := app.currentPage(); currentPage != nil {
currentPage.Render()
}
graphics.BlitToScreen()
app.Window.GLSwap()
}
// ----------------------------------------------------------------------------
// Keybind Registration
// ----------------------------------------------------------------------------
func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
app.PushPage(&page.Settings{})
})
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
downloadPage := ensureAudioFile()
if downloadPage != nil {
downloadPage.NextPage = &page.ScrollerPage{}
app.PushPage(downloadPage)
} else {
app.PushPage(&page.ScrollerPage{})
}
})
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
app.UnwindToPage(&page.Welcome{})
})
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
app.Stop()
})
keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(volumeStepSize)
})
keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(-volumeStepSize)
})
}
func (app *Application) Stop() {
app.running = false
}
// ----------------------------------------------------------------------------
// Page Management
// ----------------------------------------------------------------------------
func (app *Application) currentPage() page.Page {
if len(app.pageStack) == 0 {
return nil
}
return app.pageStack[len(app.pageStack)-1]
}
func (app *Application) applyPendingAction() {
if app.pendingAction != nil {
app.pendingAction()
app.pendingAction = nil
}
}
func (app *Application) schedulePush(p page.Page) {
app.pendingAction = func() {
if err := p.Init(app); err != nil {
logger.Error("Failed to init page on push: %v", err)
return
}
if topPage := app.currentPage(); topPage != nil {
topPage.Destroy()
}
app.pageStack = append(app.pageStack, p)
}
}
func (app *Application) scheduleSwitch(p page.Page) {
app.pendingAction = func() {
if topPage := app.currentPage(); topPage != nil {
topPage.Destroy()
app.pageStack = app.pageStack[:len(app.pageStack)-1]
}
if err := p.Init(app); err != nil {
logger.Error("Failed to init page on switch: %v", err)
return
}
app.pageStack = append(app.pageStack, p)
}
}
func (app *Application) scheduleUnwind(targetPage page.Page) {
app.pendingAction = func() {
foundIndex := -1
for i := len(app.pageStack) - 1; i >= 0; i-- {
if app.pageStack[i] == targetPage {
foundIndex = i
break
}
}
if foundIndex != -1 {
for i := len(app.pageStack) - 1; i > foundIndex; i-- {
app.pageStack[i].Destroy()
}
app.pageStack = app.pageStack[:foundIndex+1]
} else {
if topPage := app.currentPage(); topPage != nil {
topPage.Destroy()
app.pageStack = app.pageStack[:len(app.pageStack)-1]
}
if err := targetPage.Init(app); err != nil {
logger.Error("Failed to init page on unwind: %v", err)
return
}
app.pageStack = append(app.pageStack, targetPage)
}
}
}
func (app *Application) SchedulePop() {
app.pendingAction = func() {
if len(app.pageStack) == 0 {
return
}
topPage := app.pageStack[len(app.pageStack)-1]
topPage.Destroy()
app.pageStack = app.pageStack[:len(app.pageStack)-1]
if newTop := app.currentPage(); newTop != nil {
if err := newTop.Init(app); err != nil {
logger.Error("Failed to re-init page after pop: %v", err)
}
}
}
}
// ----------------------------------------------------------------------------
// Public Page Navigation Interface
// ----------------------------------------------------------------------------
func (app *Application) PushPage(p page.Page) {
app.schedulePush(p)
}
func (app *Application) SwitchPage(p page.Page) {
app.scheduleSwitch(p)
}
func (app *Application) UnwindToPage(p page.Page) {
app.scheduleUnwind(p)
}
func (app *Application) GetDrawableSize() (w, h int32) {
return graphics.VirtualWidth, graphics.VirtualHeight
}
func (app *Application) PopPage() {
app.SchedulePop()
}
// ----------------------------------------------------------------------------
// License Validation
// ----------------------------------------------------------------------------
func (app *Application) validateLicenseKey() {
logger.Info("Validating license key")
validator := license.NewValidator(apiURL, apiPath)
key, err := db.GetConfigValue("license_key")
if err != nil {
logger.Error("Failed to fetch license key from database: %v", err)
}
email, err := db.GetConfigValue("email_address")
if err != nil {
logger.Error("Failed to fetch email from database: %v", err)
}
result, err := validator.Validate(key, email)
if err != nil {
logger.Error("License validation failed: %v", err)
app.License = nil
return
}
logger.Info("License validation successful")
app.License = result
}
// ----------------------------------------------------------------------------
// Startup Page Stack
// ----------------------------------------------------------------------------
func (app *Application) startupPageStack() {
app.PushPage(&page.Welcome{})
app.applyPendingAction()
key, _ := db.GetConfigValue("license_key")
if key == "" {
logger.Info("No license key found, pushing license key page")
app.PushPage(&page.LicenseKeyPage{})
app.applyPendingAction()
} else {
// Ensure the license key file exists for the updater script.
// Older versions only stored the key in the database.
if homeDir, err := os.UserHomeDir(); err == nil {
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
logger.Info("License key file missing, writing from database")
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
logger.Error("Failed to create license key directory: %v", err)
} else if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil {
logger.Error("Failed to write license key file: %v", err)
}
}
}
}
email, _ := db.GetConfigValue("email_address")
if email == "" {
logger.Info("No email address found, pushing email address page")
app.PushPage(&page.EmailAddressPage{})
app.applyPendingAction()
}
// TOS pages — must be accepted before anything else.
// Pushed last so they sit on top of the stack (shown first).
sections, err := db.GetTOSSections()
if err != nil {
logger.Error("Failed to load TOS sections: %v", err)
}
// Push in reverse order so section 1 is on top of the stack
for i := len(sections) - 1; i >= 0; i-- {
s := sections[i]
configKey := fmt.Sprintf("tos_section_%d_accepted", s.SectionNumber)
accepted, _ := db.GetConfigValue(configKey)
if accepted != "true" {
app.PushPage(&page.TOSPage{
Title: s.Title,
Content: s.Content,
ConfigKey: configKey,
})
app.applyPendingAction()
}
}
// Internet check — pushed last so it appears first if no internet.
// Skip WiFi setup if ethernet is connected (it auto-configures) or if
// internet is already reachable through any interface.
nm, err := network.New()
if err != nil {
logger.Warning("Could not initialize network manager: %v", err)
} else {
status, err := nm.CheckInternetConnection()
if err != nil {
logger.Warning("Network connectivity check failed: %v", err)
} else {
switch status.StatusCode {
case network.StatusEthernetConnectedInternetUp,
network.StatusEthernetConnectedNoInternet,
network.StatusWifiConnectedInternetUp:
// Internet is up or ethernet is connected and will auto-configure
default:
logger.Info("No internet connection, pushing WiFi setup page")
app.PushPage(&page.WiFiSetupPage{})
app.applyPendingAction()
}
}
}
}
func (app *Application) Quit() {
logger.Info("User has quit the application")
app.running = false
}
+192
View File
@@ -0,0 +1,192 @@
// Package sdlcolors provides a comprehensive palette of 256 standard colors
// for use with the Go SDL2 bindings. Each color is a public variable of
// type sdl.Color, making them easily accessible for rendering tasks.
package colors
import "github.com/veandco/go-sdl2/sdl"
// Basic Colors
var (
White = sdl.Color{R: 255, G: 255, B: 255, A: 255}
Silver = sdl.Color{R: 192, G: 192, B: 192, A: 255}
Gray = sdl.Color{R: 128, G: 128, B: 128, A: 255}
Black = sdl.Color{R: 0, G: 0, B: 0, A: 255}
Red = sdl.Color{R: 255, G: 0, B: 0, A: 255}
Maroon = sdl.Color{R: 128, G: 0, B: 0, A: 255}
Yellow = sdl.Color{R: 255, G: 255, B: 0, A: 255}
Olive = sdl.Color{R: 128, G: 128, B: 0, A: 255}
Lime = sdl.Color{R: 0, G: 255, B: 0, A: 255}
Green = sdl.Color{R: 0, G: 128, B: 0, A: 255}
Aqua = sdl.Color{R: 0, G: 255, B: 255, A: 255}
Teal = sdl.Color{R: 0, G: 128, B: 128, A: 255}
Blue = sdl.Color{R: 0, G: 0, B: 255, A: 255}
Navy = sdl.Color{R: 0, G: 0, B: 128, A: 255}
Fuchsia = sdl.Color{R: 255, G: 0, B: 255, A: 255}
Purple = sdl.Color{R: 128, G: 0, B: 128, A: 255}
)
// Grayscale
var (
Gainsboro = sdl.Color{R: 220, G: 220, B: 220, A: 255}
LightGray = sdl.Color{R: 211, G: 211, B: 211, A: 255}
DarkGray = sdl.Color{R: 169, G: 169, B: 169, A: 255}
DimGray = sdl.Color{R: 105, G: 105, B: 105, A: 255}
LightSlateGray = sdl.Color{R: 119, G: 136, B: 153, A: 255}
SlateGray = sdl.Color{R: 112, G: 128, B: 144, A: 255}
DarkSlateGray = sdl.Color{R: 47, G: 79, B: 79, A: 255}
)
// Error Colors
var (
ErrorRed = sdl.Color{R: 255, G: 80, B: 80, A: 255}
)
// Pinks & Reds
var (
Pink = sdl.Color{R: 255, G: 192, B: 203, A: 255}
LightPink = sdl.Color{R: 255, G: 182, B: 193, A: 255}
HotPink = sdl.Color{R: 255, G: 105, B: 180, A: 255}
DeepPink = sdl.Color{R: 255, G: 20, B: 147, A: 255}
PaleVioletRed = sdl.Color{R: 219, G: 112, B: 147, A: 255}
MediumVioletRed = sdl.Color{R: 199, G: 21, B: 133, A: 255}
LightSalmon = sdl.Color{R: 255, G: 160, B: 122, A: 255}
Salmon = sdl.Color{R: 250, G: 128, B: 114, A: 255}
DarkSalmon = sdl.Color{R: 233, G: 150, B: 122, A: 255}
LightCoral = sdl.Color{R: 240, G: 128, B: 128, A: 255}
IndianRed = sdl.Color{R: 205, G: 92, B: 92, A: 255}
Crimson = sdl.Color{R: 220, G: 20, B: 60, A: 255}
Firebrick = sdl.Color{R: 178, G: 34, B: 34, A: 255}
DarkRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
)
// Oranges
var (
OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255}
Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255}
Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255}
DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255}
Orange = sdl.Color{R: 255, G: 165, B: 0, A: 255}
)
// Yellows
var (
Gold = sdl.Color{R: 255, G: 215, B: 0, A: 255}
LightYellow = sdl.Color{R: 255, G: 255, B: 224, A: 255}
LemonChiffon = sdl.Color{R: 255, G: 250, B: 205, A: 255}
LightGoldenrodYellow = sdl.Color{R: 250, G: 250, B: 210, A: 255}
PapayaWhip = sdl.Color{R: 255, G: 239, B: 213, A: 255}
Moccasin = sdl.Color{R: 255, G: 228, B: 181, A: 255}
PeachPuff = sdl.Color{R: 255, G: 218, B: 185, A: 255}
PaleGoldenrod = sdl.Color{R: 238, G: 232, B: 170, A: 255}
Khaki = sdl.Color{R: 240, G: 230, B: 140, A: 255}
DarkKhaki = sdl.Color{R: 189, G: 183, B: 107, A: 255}
)
// Purples & Violets
var (
Lavender = sdl.Color{R: 230, G: 230, B: 250, A: 255}
Thistle = sdl.Color{R: 216, G: 191, B: 216, A: 255}
Plum = sdl.Color{R: 221, G: 160, B: 221, A: 255}
Violet = sdl.Color{R: 238, G: 130, B: 238, A: 255}
Orchid = sdl.Color{R: 218, G: 112, B: 214, A: 255}
Magenta = sdl.Color{R: 255, G: 0, B: 255, A: 255}
MediumOrchid = sdl.Color{R: 186, G: 85, B: 211, A: 255}
MediumPurple = sdl.Color{R: 147, G: 112, B: 219, A: 255}
RebeccaPurple = sdl.Color{R: 102, G: 51, B: 153, A: 255}
BlueViolet = sdl.Color{R: 138, G: 43, B: 226, A: 255}
DarkViolet = sdl.Color{R: 148, G: 0, B: 211, A: 255}
DarkOrchid = sdl.Color{R: 153, G: 50, B: 204, A: 255}
DarkMagenta = sdl.Color{R: 139, G: 0, B: 139, A: 255}
Indigo = sdl.Color{R: 75, G: 0, B: 130, A: 255}
SlateBlue = sdl.Color{R: 106, G: 90, B: 205, A: 255}
DarkSlateBlue = sdl.Color{R: 72, G: 61, B: 139, A: 255}
)
// Greens
var (
GreenYellow = sdl.Color{R: 173, G: 255, B: 47, A: 255}
Chartreuse = sdl.Color{R: 127, G: 255, B: 0, A: 255}
LawnGreen = sdl.Color{R: 124, G: 252, B: 0, A: 255}
LimeGreen = sdl.Color{R: 50, G: 205, B: 50, A: 255}
PaleGreen = sdl.Color{R: 152, G: 251, B: 152, A: 255}
LightGreen = sdl.Color{R: 144, G: 238, B: 144, A: 255}
MediumSpringGreen = sdl.Color{R: 0, G: 250, B: 154, A: 255}
SpringGreen = sdl.Color{R: 0, G: 255, B: 127, A: 255}
MediumSeaGreen = sdl.Color{R: 60, G: 179, B: 113, A: 255}
SeaGreen = sdl.Color{R: 46, G: 139, B: 87, A: 255}
ForestGreen = sdl.Color{R: 34, G: 139, B: 34, A: 255}
DarkGreen = sdl.Color{R: 0, G: 100, B: 0, A: 255}
YellowGreen = sdl.Color{R: 154, G: 205, B: 50, A: 255}
OliveDrab = sdl.Color{R: 107, G: 142, B: 35, A: 255}
DarkOliveGreen = sdl.Color{R: 85, G: 107, B: 47, A: 255}
MediumAquamarine = sdl.Color{R: 102, G: 205, B: 170, A: 255}
DarkSeaGreen = sdl.Color{R: 143, G: 188, B: 143, A: 255}
LightSeaGreen = sdl.Color{R: 32, G: 178, B: 170, A: 255}
DarkCyan = sdl.Color{R: 0, G: 139, B: 139, A: 255}
)
// Blues & Cyans
var (
LightCyan = sdl.Color{R: 224, G: 255, B: 255, A: 255}
Cyan = sdl.Color{R: 0, G: 255, B: 255, A: 255}
AquaMarine = sdl.Color{R: 127, G: 255, B: 212, A: 255}
PaleTurquoise = sdl.Color{R: 175, G: 238, B: 238, A: 255}
Turquoise = sdl.Color{R: 64, G: 224, B: 208, A: 255}
MediumTurquoise = sdl.Color{R: 72, G: 209, B: 204, A: 255}
DarkTurquoise = sdl.Color{R: 0, G: 206, B: 209, A: 255}
CadetBlue = sdl.Color{R: 95, G: 158, B: 160, A: 255}
SteelBlue = sdl.Color{R: 70, G: 130, B: 180, A: 255}
LightSteelBlue = sdl.Color{R: 176, G: 196, B: 222, A: 255}
PowderBlue = sdl.Color{R: 176, G: 224, B: 230, A: 255}
LightBlue = sdl.Color{R: 173, G: 216, B: 230, A: 255}
SkyBlue = sdl.Color{R: 135, G: 206, B: 235, A: 255}
LightSkyBlue = sdl.Color{R: 135, G: 206, B: 250, A: 255}
DeepSkyBlue = sdl.Color{R: 0, G: 191, B: 255, A: 255}
DodgerBlue = sdl.Color{R: 30, G: 144, B: 255, A: 255}
CornflowerBlue = sdl.Color{R: 100, G: 149, B: 237, A: 255}
MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255}
RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, A: 255}
MediumBlue = sdl.Color{R: 0, G: 0, B: 205, A: 255}
DarkBlue = sdl.Color{R: 0, G: 0, B: 139, A: 255}
MidnightBlue = sdl.Color{R: 25, G: 25, B: 112, A: 255}
)
// Browns
var (
Cornsilk = sdl.Color{R: 255, G: 248, B: 220, A: 255}
BlanchedAlmond = sdl.Color{R: 255, G: 235, B: 205, A: 255}
Bisque = sdl.Color{R: 255, G: 228, B: 196, A: 255}
NavajoWhite = sdl.Color{R: 255, G: 222, B: 173, A: 255}
Wheat = sdl.Color{R: 245, G: 222, B: 179, A: 255}
BurlyWood = sdl.Color{R: 222, G: 184, B: 135, A: 255}
Tan = sdl.Color{R: 210, G: 180, B: 140, A: 255}
RosyBrown = sdl.Color{R: 188, G: 143, B: 143, A: 255}
SandyBrown = sdl.Color{R: 244, G: 164, B: 96, A: 255}
Goldenrod = sdl.Color{R: 218, G: 165, B: 32, A: 255}
DarkGoldenrod = sdl.Color{R: 184, G: 134, B: 11, A: 255}
Peru = sdl.Color{R: 205, G: 133, B: 63, A: 255}
Chocolate = sdl.Color{R: 210, G: 105, B: 30, A: 255}
SaddleBrown = sdl.Color{R: 139, G: 69, B: 19, A: 255}
Sienna = sdl.Color{R: 160, G: 82, B: 45, A: 255}
Brown = sdl.Color{R: 165, G: 42, B: 42, A: 255}
)
// Whites
var (
Snow = sdl.Color{R: 255, G: 250, B: 250, A: 255}
Honeydew = sdl.Color{R: 240, G: 255, B: 240, A: 255}
MintCream = sdl.Color{R: 245, G: 255, B: 250, A: 255}
Azure = sdl.Color{R: 240, G: 255, B: 255, A: 255}
AliceBlue = sdl.Color{R: 240, G: 248, B: 255, A: 255}
GhostWhite = sdl.Color{R: 248, G: 248, B: 255, A: 255}
WhiteSmoke = sdl.Color{R: 245, G: 245, B: 245, A: 255}
Seashell = sdl.Color{R: 255, G: 245, B: 238, A: 255}
Beige = sdl.Color{R: 245, G: 245, B: 220, A: 255}
OldLace = sdl.Color{R: 253, G: 245, B: 230, A: 255}
FloralWhite = sdl.Color{R: 255, G: 250, B: 240, A: 255}
Ivory = sdl.Color{R: 255, G: 255, B: 240, A: 255}
AntiqueWhite = sdl.Color{R: 250, G: 235, B: 215, A: 255}
Linen = sdl.Color{R: 250, G: 240, B: 230, A: 255}
LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255}
MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255}
)
+488
View File
@@ -0,0 +1,488 @@
package db
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/logger"
)
var DB *gorm.DB
// configCache is an in-memory cache of config values to avoid repeated DB queries.
var configCache = struct {
sync.RWMutex
m map[string]string
}{m: make(map[string]string)}
type Config struct {
gorm.Model
Key string `gorm:"uniqueIndex"`
Value string
}
type Proclamations struct {
gorm.Model
Title string
Content string
Commercial bool
Selected bool
Favorite bool
Available bool
}
type Log struct {
gorm.Model
Timestamp string
Level string
Message string
}
func (Log) TableName() string {
return "log_entries"
}
type TOSSection struct {
gorm.Model
SectionNumber int `gorm:"uniqueIndex"`
Title string
Content string // raw HTML
}
var defaultConfigValues = map[string]string{
"home_dir": "",
"assets_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave
"log_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave/logs.log
"email_address": "",
"license_key": "",
"system_type": "home",
"system_capabilities": "",
"selected_audio": "audio/audio.wav",
"audio_device_name": "",
"application_font": "Roboto-Regular",
"init_font_size": "128",
"standard_font_size": "32",
"line_pattern": "fibonacci",
"last_volume": "128",
"audio_enabled": "true",
"tos_section_1_accepted": "false",
"tos_section_2_accepted": "false",
"tos_section_3_accepted": "false",
"tos_section_4_accepted": "false",
"tos_section_5_accepted": "false",
"tos_section_6_accepted": "false",
"tos_section_7_accepted": "false",
"tos_section_8_accepted": "false",
"tos_section_9_accepted": "false",
"tos_section_10_accepted": "false",
}
// InitDatabase initializes the SQLite database connection
// and performs auto-migration for all models
func InitDatabase(dbpath string) error {
// Create the directory for the database if it doesn't exist
dbDir := filepath.Dir(dbpath)
if err := os.MkdirAll(dbDir, 0700); err != nil {
log.Printf("Error creating database directory %s: %v", dbDir, err)
return err
}
// Check if database file exists
_, err := os.Stat(dbpath)
dbExists := !os.IsNotExist(err)
if !dbExists {
log.Printf("Database file does not exist at %s, creating new database", dbpath)
}
dsn := fmt.Sprintf("file:%s", dbpath)
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
log.Printf("Error opening database: %v", err)
return err
}
// Get underlying sql.DB to set pragmas
sqlDB, err := DB.DB()
if err != nil {
logger.Error("Error getting underlying database connection: %v", err)
return err
}
// Configure SQLite for reliability and performance
pragmas := []string{
"PRAGMA journal_mode=WAL", // Write-Ahead Logging for better concurrency
"PRAGMA synchronous=FULL", // Maximum durability
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
"PRAGMA busy_timeout=5000", // Wait up to 5 seconds for locks
"PRAGMA temp_store=MEMORY", // Keep temp tables in memory
}
for _, pragma := range pragmas {
if _, err := sqlDB.Exec(pragma); err != nil {
logger.Error("Error setting pragma %s: %v", pragma, err)
return fmt.Errorf("failed to set pragma: %w", err)
}
}
if err := sqlDB.Ping(); err != nil {
logger.Error("Error pinging database: %v", err)
return fmt.Errorf("database connection failed: %w", err)
}
log.Println("Database connected successfully")
// Auto-migrate models
err = DB.AutoMigrate(&Config{}, &Proclamations{}, &Log{}, &TOSSection{})
if err != nil {
logger.Error("Error migrating database: %v", err)
return err
}
log.Println("Database migrated successfully")
// Seed defaults
err = ensureDefaultsExist()
if err != nil {
logger.Error("Error seeding default configuration values: %v", err)
return err
}
err = seedDefaultProclamations()
if err != nil {
logger.Error("Error seeding default proclamations: %v", err)
return err
}
err = seedDefaultTOSSections()
if err != nil {
logger.Error("Error seeding default TOS sections: %v", err)
return err
}
log.Println("Database initialized successfully")
return nil
}
// seedDefaultProclamations reads proclamation text files from the assets directory
// and populates the database on first run
func seedDefaultProclamations() error {
var count int64
err := DB.Model(&Proclamations{}).Count(&count).Error
if err != nil {
logger.Error("Error counting proclamations: %v", err)
return err
}
// Only seed if table is empty
if count > 0 {
return nil
}
// Get the assets directory from config
assetsDir, err := GetConfigValue("assets_dir")
if err != nil {
logger.Error("Error getting assets_dir: %v", err)
return err
}
proclamationsBaseDir := assetsDir + "/proclamations"
// Process both home and commercial directories
subdirs := []string{"home", "commercial"}
totalSeeded := 0
for _, subdir := range subdirs {
proclamationsDir := proclamationsBaseDir + "/" + subdir
isCommercial := (subdir == "commercial")
// Read all .txt files from the subdirectory
files, err := os.ReadDir(proclamationsDir)
if err != nil {
logger.Error("Error reading proclamations directory %s: %v", proclamationsDir, err)
continue // Continue with other directories even if one fails
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".txt") {
continue
}
filePath := proclamationsDir + "/" + file.Name()
content, err := os.ReadFile(filePath)
if err != nil {
logger.Error("Error reading file %s: %v", filePath, err)
continue
}
// Extract title from filename (remove .txt extension)
title := strings.TrimSuffix(file.Name(), ".txt")
// Select "Relaxation" as the default proclamation
isSelected := (title == "Relaxation")
proclamation := Proclamations{
Title: title,
Content: string(content),
Commercial: isCommercial,
Selected: isSelected,
Favorite: false,
Available: true,
}
if err := DB.Create(&proclamation).Error; err != nil {
logger.Error("Error creating proclamation from %s: %v", file.Name(), err)
return err
}
if isSelected {
logger.Error("Set default proclamation: %s", title)
}
totalSeeded++
}
}
logger.Error("Seeded %d default proclamations from %s", totalSeeded, proclamationsBaseDir)
return nil
}
// GetConfigValue retrieves a configuration value by key.
// Results are cached in memory to avoid repeated DB queries.
func GetConfigValue(key string) (string, error) {
configCache.RLock()
if v, ok := configCache.m[key]; ok {
configCache.RUnlock()
return v, nil
}
configCache.RUnlock()
var config Config
result := DB.First(&config, "key = ?", key)
if result.Error != nil {
return "", result.Error
}
configCache.Lock()
configCache.m[key] = config.Value
configCache.Unlock()
return config.Value, nil
}
// GetConfigValues retrieves all configuration key-value pairs
// Returns a map of key-value pairs OR any error encountered
// This is mainly for compatibility with legacy code, but can
// be useful for bulkl config referrals
func GetConfigValues() (map[string]string, error) {
var configs []Config
result := DB.Find(&configs)
if result.Error != nil {
return nil, result.Error
}
configMap := make(map[string]string)
for _, config := range configs {
configMap[config.Key] = config.Value
}
return configMap, nil
}
// SetConfigValue sets a configuration value by key
// Returns any error encountered || nil
func SetConfigValue(key, value string) error {
// First try to find existing config
var config Config
result := DB.Where("key = ?", key).First(&config)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// Record doesn't exist, create new one
config = Config{Key: key, Value: value}
if err := DB.Create(&config).Error; err != nil {
return fmt.Errorf("failed to create config value for key %s: %w", key, err)
}
} else {
// Some other error occurred
return fmt.Errorf("failed to query config for key %s: %w", key, result.Error)
}
} else {
// Record exists, update it
config.Value = value
if err := DB.Save(&config).Error; err != nil {
return fmt.Errorf("failed to update config value for key %s: %w", key, err)
}
}
// Update cache
configCache.Lock()
configCache.m[key] = value
configCache.Unlock()
return nil
}
// GetProclamations retrieves all proclamation entries from the database
// Returns a slice of Proclamations OR any error encountered
func GetProclamations() ([]Proclamations, error) {
var proclamations []Proclamations
result := DB.Find(&proclamations)
if result.Error != nil {
return nil, result.Error
}
return proclamations, nil
}
// seedDefaultTOSSections reads HTML files from the assets directory and populates the
// TOS sections table on first run.
func seedDefaultTOSSections() error {
var count int64
if err := DB.Model(&TOSSection{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
assetsDir, err := GetConfigValue("assets_dir")
if err != nil {
return fmt.Errorf("getting assets_dir: %w", err)
}
tosDir := filepath.Join(assetsDir, "tos")
files, err := os.ReadDir(tosDir)
if err != nil {
return fmt.Errorf("reading tos directory %s: %w", tosDir, err)
}
seeded := 0
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".html") {
continue
}
// Parse filename: "01 - WELCOME.html" -> number=1, title="WELCOME"
name := strings.TrimSuffix(file.Name(), ".html")
parts := strings.SplitN(name, " - ", 2)
if len(parts) != 2 {
logger.Error("Skipping TOS file with unexpected name format: %s", file.Name())
continue
}
var sectionNum int
if _, err := fmt.Sscanf(parts[0], "%d", &sectionNum); err != nil {
logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
continue
}
title := strings.TrimSpace(parts[1])
content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
if err != nil {
logger.Error("Error reading TOS file %s: %v", file.Name(), err)
continue
}
section := TOSSection{
SectionNumber: sectionNum,
Title: title,
Content: string(content),
}
if err := DB.Create(&section).Error; err != nil {
return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
}
seeded++
}
logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
return nil
}
// GetTOSSections returns all TOS sections ordered by section number.
func GetTOSSections() ([]TOSSection, error) {
var sections []TOSSection
result := DB.Order("section_number asc").Find(&sections)
if result.Error != nil {
return nil, result.Error
}
return sections, nil
}
// GetLogs retrieves log entries from the database, ordered by creation time ascending.
// If limit > 0, at most that many entries are returned.
func GetLogs(limit int) ([]Log, error) {
var logs []Log
query := DB.Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
return logs, query.Find(&logs).Error
}
// ensureDefaultsExist checks the current configs table rows and inserts any new rows
// from defaultConfigValues to the database if they don't exist.
// This is essentially an automatic migration for new config values.
func ensureDefaultsExist() error {
for key, value := range defaultConfigValues {
var config Config
result := DB.Where("key = ?", key).First(&config)
// Always re-resolve path-based config values from the current binary location
switch key {
case "assets_dir":
value = resolveAssetsDir()
case "log_dir":
value = filepath.Join(resolveAssetsDir(), "logs.log")
}
if result.Error == gorm.ErrRecordNotFound {
// Key doesn't exist, create it with default value
if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
return fmt.Errorf("failed to create default config for %s: %w", key, err)
}
log.Printf("Added missing config key: %s", key)
} else if result.Error != nil {
return result.Error
} else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
// Update stale path-based values (e.g. after Nix store update)
config.Value = value
if err := DB.Save(&config).Error; err != nil {
return fmt.Errorf("failed to update config for %s: %w", key, err)
}
log.Printf("Updated stale config key: %s", key)
} else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
// Migrate renamed audio file
config.Value = value
if err := DB.Save(&config).Error; err != nil {
return fmt.Errorf("failed to migrate config for %s: %w", key, err)
}
log.Printf("Migrated config key: %s", key)
}
}
return nil
}
func AudioEnabled() bool {
on, err := GetConfigValue("audio_enabled")
if err != nil {
return false
}
return on == "true"
}
// resolveAssetsDir returns the assets directory by resolving relative to the
// running binary. For a binary at .../bin/rhemawave, assets are at
// .../share/rhemawave. Falls back to /usr/local/share/rhemawave.
func resolveAssetsDir() string {
exe, err := os.Executable()
if err == nil {
exe, err = filepath.EvalSymlinks(exe)
}
if err == nil {
return filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")
}
return filepath.Join("/usr", "local", "share", "rhemawave")
}
+829
View File
@@ -0,0 +1,829 @@
package fontManager
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/graphics"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// FontManager handles the loading and management of fonts.
type FontManager struct {
Fonts map[string]*FontEntry
// Reusable GPU objects for string texture rendering
stringFBO uint32
stringVAO uint32
stringVBO uint32
}
var (
fontManagerInstance *FontManager
fontManagerOnce sync.Once
initErr error // Package-level variable to capture error from within sync.Once
)
// InitFontManager initializes the FontManager singleton.
// It must be called once at startup after ShaderManager has been initialized.
func InitFontManager() error {
fontManagerOnce.Do(func() {
// Dependency check: Ensure ShaderManager is ready
sm := shaderManager.Get()
if sm == nil {
initErr = fmt.Errorf("cannot initialize FontManager because ShaderManager is not initialized")
return // Exit the anonymous function
}
fm := &FontManager{
Fonts: make(map[string]*FontEntry),
}
assetsDir, err := db.GetConfigValue("assets_dir")
if err != nil {
initErr = fmt.Errorf("could not get assets_dir from config: %w", err)
return
}
fontPath := filepath.Join(assetsDir, "fonts")
if err := fm.loadFonts(fontPath); err != nil {
initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err)
return
}
// --- NEW: Create the scrambled font after loading normal fonts ---
// We will use "DejaVuSans" as the base. If you use a different default font,
// change this string.
baseFontForScramble := "Roboto-Regular"
if _, ok := fm.Fonts[baseFontForScramble]; ok {
if err := fm.CreateScrambledFont(baseFontForScramble, "Scrambled"); err != nil {
// This is not a fatal error for the whole application, so we just log it.
}
} else {
}
// --- END NEW ---
// Create reusable GPU objects for string texture rendering
gl.GenFramebuffers(1, &fm.stringFBO)
gl.GenVertexArrays(1, &fm.stringVAO)
gl.GenBuffers(1, &fm.stringVBO)
// Set up VAO attribute layout once
gl.BindVertexArray(fm.stringVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.DYNAMIC_DRAW) // 6 vertices * 4 floats * 4 bytes
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
gl.BindVertexArray(0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
fontManagerInstance = fm
})
// Return the error that might have been captured inside the sync.Once block.
return initErr
}
// Get returns the singleton instance of the FontManager.
// It will panic if InitFontManager has not been called successfully first.
func Get() *FontManager {
if fontManagerInstance == nil {
panic("FontManager has not been initialized or initialization failed. Call InitFontManager at application startup.")
}
return fontManagerInstance
}
// FontEntry combines related Font information together
type FontEntry struct {
Name string
Font *ttf.Font
Map *FontMap
}
// FontMap contains the character map for a specific font
type FontMap struct {
FontMapTextureID uint32 // Memory address of the FontMapTexture within the GPU
Width int32 // The width of the font map texture
Height int32 // The height of the font map FontMap Texture
Glyphs map[rune]Glyph // A map of runes and their locations within the Fontmap
padding int32
}
// TempGlyph is a temporary struct used only during font map creation.
type TempGlyph struct {
Rune rune
Advance int
surface *sdl.Surface
}
// Glyph contains the rendering information for a single character
type Glyph struct {
Rune rune // The character this glyph represents.
SrcRect sdl.Rect // The source rectangle in the texture atlas.
Advance int // How far to advance the cursor after drawing this glyph.
TextureID uint32 // The ID of the texture atlas this glyph belongs to.
}
// loadFonts scans the fonts directory, loads all .ttf/.otf files, and creates a font map for each.
func (fm *FontManager) loadFonts(fontDir string) error {
files, err := os.ReadDir(fontDir)
if err != nil {
return fmt.Errorf("could not read font directory '%s': %w", fontDir, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
fileName := file.Name()
if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") {
fontPath := filepath.Join(fontDir, fileName)
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
if err != nil {
return fmt.Errorf("could not get standard_font_size from config: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("could not convert init_font_size to int: %w", err)
}
font, err := ttf.OpenFont(fontPath, baseFontSize) // Using a higher resolution for better quality
if err != nil {
continue
}
fontName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
fm.Fonts[fontName] = &FontEntry{
Name: fontName,
Font: font,
Map: nil,
}
}
}
for _, fontEntry := range fm.Fonts {
if err := fm.createFontMap(fontEntry, false, nil); err != nil {
return fmt.Errorf("unable to create Font Map for font: %s : %v", fontEntry.Name, err)
}
}
return nil
}
// CreateScrambledFont generates a new font entry by scrambling the glyphs of a base font.
func (fm *FontManager) CreateScrambledFont(baseFontName string, newFontName string) error {
baseFont, ok := fm.Fonts[baseFontName]
if !ok {
return fmt.Errorf("base font '%s' not found", baseFontName)
}
scrambledFontEntry := &FontEntry{
Name: newFontName,
Font: baseFont.Font, // It uses the same underlying TTF font object for rendering
Map: nil,
}
if err := fm.createFontMap(scrambledFontEntry, true, baseFont); err != nil {
return fmt.Errorf("unable to create scrambled font map: %w", err)
}
fm.Fonts[newFontName] = scrambledFontEntry
return nil
}
// ATTENTION: TED
//
// # Deterministic Fibonacci-Based Visual Pattern Generation for Character Data
//
// This method transforms rendered text characters into structured visual patterns that produce
// character-specific spatial frequency signatures while maintaining perfect reversibility.
//
// The technology addresses a fundamental limitation in subliminal text display: conventional
// approaches either use unmodified text (consciously readable, defeating the subliminal purpose)
// or simple blurring/masking (destroys information content and character-specific structure).
// Our method preserves complete character information in a transformed visual state that remains
// illegible to conscious observation while generating structured spatial patterns.
//
// The approach treats each rendered character as a two-dimensional grid and applies deterministic
// geometric transformations parameterized by Fibonacci sequence values. The character's Unicode
// value seeds all transformation parameters, ensuring identical characters produce identical
// patterns while different characters generate distinct spatial signatures. Multiple transformation
// stages compound to create high-entropy visual output with underlying mathematical structure.
//
// Key differentiators from existing solutions:
// - Operates on rendered visual data rather than underlying text data or encrypted values
// - Generates character-specific spatial frequency patterns rather than uniform noise
// - Uses Fibonacci-sequence-derived parameters to create mathematically structured transformations
// - Maintains pixel-perfect reversibility despite high visual entropy
// - Produces deterministic, reproducible patterns for each character
//
// Within our Rhema Wave generation system, these transformed characters create spatial interference
// patterns designed for subconscious processing. Each character contributes a unique frequency
// signature to the overall visual field, with Fibonacci-based geometry intended to create harmonic
// relationships between character patterns. The result is a consciously illegible display that
// maintains structured information content in the spatial domain, forming a component of the
// broader Rhema Wave output.
func (fm *FontManager) scrambleSurface(surface *sdl.Surface, r rune) error {
if surface.W < 8 || surface.H < 8 {
return nil
}
if err := surface.Lock(); err != nil {
return fmt.Errorf("could not lock surface for scrambling: %w", err)
}
defer surface.Unlock()
W := int(surface.W)
H := int(surface.H)
// Use much finer grid for complete illegibility
// Fibonacci number determines grid size: 5, 8, or 13
fib := []int{5, 8, 13}
fibIndex := int(r) % len(fib)
gridSize := fib[fibIndex]
// Ensure we have at least 8x8 grid for strong scrambling
if gridSize < 8 {
gridSize = 8
}
blockW := W / gridSize
blockH := H / gridSize
if blockW == 0 || blockH == 0 {
// Surface too small, fall back to 4x4
gridSize = 4
blockW = W / gridSize
blockH = H / gridSize
if blockW == 0 || blockH == 0 {
return nil
}
}
numBlocks := gridSize * gridSize
// Create complete permutation using Fibonacci linear congruential generator
blockIndices := make([]int, numBlocks)
// Use Fibonacci numbers for the LCG parameters
// F(7)=13, F(8)=21, F(9)=34, F(10)=55, F(11)=89
fibSeq := []int{13, 21, 34, 55, 89, 144}
multiplier := fibSeq[int(r)%len(fibSeq)]
increment := fibSeq[(int(r)+1)%len(fibSeq)]
// Generate pseudo-random permutation
for i := 0; i < numBlocks; i++ {
blockIndices[i] = (multiplier*i + increment*int(r)) % numBlocks
}
// Additional scrambling: apply multiple Fibonacci-based transformations
numTransforms := 3 + (int(r) % 5) // 3-7 transformations
for t := 0; t < numTransforms; t++ {
transformType := (int(r) + t*fibSeq[t%len(fibSeq)]) % 5
switch transformType {
case 0: // Reverse sections
sectionSize := gridSize / 2
for i := 0; i < numBlocks; i += sectionSize {
end := i + sectionSize
if end > numBlocks {
end = numBlocks
}
reverseSlice(blockIndices[i:end])
}
case 1: // Spiral rotation
// Rotate blocks in a spiral pattern
for layer := 0; layer < gridSize/2; layer++ {
rotateLayer(blockIndices, gridSize, layer)
}
case 2: // Fibonacci walk swap
step := fib[t%len(fib)]
for i := 0; i < numBlocks-step; i += step {
blockIndices[i], blockIndices[i+step] = blockIndices[i+step], blockIndices[i]
}
case 3: // Checkerboard transpose
for i := 0; i < gridSize; i++ {
for j := 0; j < gridSize; j++ {
if (i+j)%2 == 0 {
idx1 := i*gridSize + j
idx2 := j*gridSize + i
if idx2 < numBlocks {
blockIndices[idx1], blockIndices[idx2] = blockIndices[idx2], blockIndices[idx1]
}
}
}
}
case 4: // Random-walk permutation
walkLen := fib[(t+1)%len(fib)]
pos := int(r+rune(t)) % numBlocks
for w := 0; w < walkLen && pos < numBlocks-1; w++ {
nextPos := (pos + fib[w%len(fib)]) % numBlocks
blockIndices[pos], blockIndices[nextPos] = blockIndices[nextPos], blockIndices[pos]
pos = nextPos
}
}
}
// Copy pixels to perform the scrambling
pixels := surface.Pixels()
bytesPerPixel := int(surface.Format.BytesPerPixel)
pitch := int(surface.Pitch)
originalPixels := make([]byte, len(pixels))
copy(originalPixels, pixels)
// Rearrange all blocks
for dstIdx := 0; dstIdx < numBlocks; dstIdx++ {
srcIdx := blockIndices[dstIdx]
srcCol := srcIdx % gridSize
srcRow := srcIdx / gridSize
dstCol := dstIdx % gridSize
dstRow := dstIdx / gridSize
srcX := srcCol * blockW
srcY := srcRow * blockH
dstX := dstCol * blockW
dstY := dstRow * blockH
// Copy block pixel by pixel
for y := 0; y < blockH; y++ {
if srcY+y >= H || dstY+y >= H {
continue
}
srcOffset := (srcY+y)*pitch + srcX*bytesPerPixel
dstOffset := (dstY+y)*pitch + dstX*bytesPerPixel
rowLen := blockW * bytesPerPixel
if srcX+blockW > W {
rowLen = (W - srcX) * bytesPerPixel
}
if dstX+blockW > W {
maxLen := (W - dstX) * bytesPerPixel
if maxLen < rowLen {
rowLen = maxLen
}
}
if srcOffset+rowLen <= len(originalPixels) && dstOffset+rowLen <= len(pixels) {
copy(pixels[dstOffset:dstOffset+rowLen], originalPixels[srcOffset:srcOffset+rowLen])
}
}
}
return nil
}
// Helper function to reverse a slice
func reverseSlice(arr []int) {
for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 {
arr[i], arr[j] = arr[j], arr[i]
}
}
// Helper function to rotate a layer in spiral pattern
func rotateLayer(arr []int, gridSize, layer int) {
if layer >= gridSize/2 {
return
}
// Extract the layer elements
var temp []int
// Top edge
for i := layer; i < gridSize-layer; i++ {
temp = append(temp, arr[layer*gridSize+i])
}
// Right edge
for i := layer + 1; i < gridSize-layer; i++ {
temp = append(temp, arr[i*gridSize+(gridSize-layer-1)])
}
// Bottom edge
if gridSize-layer-1 > layer {
for i := gridSize - layer - 2; i >= layer; i-- {
temp = append(temp, arr[(gridSize-layer-1)*gridSize+i])
}
}
// Left edge
if gridSize-layer-1 > layer {
for i := gridSize - layer - 2; i > layer; i-- {
temp = append(temp, arr[i*gridSize+layer])
}
}
// Rotate the temp array by 1 position
if len(temp) > 0 {
last := temp[len(temp)-1]
copy(temp[1:], temp[0:len(temp)-1])
temp[0] = last
}
// Put back the rotated elements
idx := 0
// Top edge
for i := layer; i < gridSize-layer && idx < len(temp); i++ {
arr[layer*gridSize+i] = temp[idx]
idx++
}
// Right edge
for i := layer + 1; i < gridSize-layer && idx < len(temp); i++ {
arr[i*gridSize+(gridSize-layer-1)] = temp[idx]
idx++
}
// Bottom edge
if gridSize-layer-1 > layer {
for i := gridSize - layer - 2; i >= layer && idx < len(temp); i-- {
arr[(gridSize-layer-1)*gridSize+i] = temp[idx]
idx++
}
}
// Left edge
if gridSize-layer-1 > layer {
for i := gridSize - layer - 2; i > layer && idx < len(temp); i-- {
arr[i*gridSize+layer] = temp[idx]
idx++
}
}
}
// createFontMap generates a texture atlas for a given font entry.
func (fm *FontManager) createFontMap(font *FontEntry, scramble bool, baseFont *FontEntry) error {
var chars []rune
for i := rune(32); i <= 126; i++ { // Common ASCII characters
chars = append(chars, i)
}
tempGlyphs := make([]TempGlyph, 0, len(chars))
var totalWidth, maxHeight int32
padding := int32(2) // 2 pixels of padding between glyphs
// If scrambling, use the base font's metrics but our new entry's name
fontToRenderFrom := font
if scramble && baseFont != nil {
fontToRenderFrom = baseFont
}
for _, char := range chars {
tempGlyph, err := fm.createTempGlyph(fontToRenderFrom, char)
if err != nil {
continue
}
// --- NEW: Scramble the surface if requested ---
if scramble {
if err := fm.scrambleSurface(tempGlyph.surface, char); err != nil {
// We can continue, it will just use the unscrambled glyph
}
}
// --- END NEW ---
tempGlyphs = append(tempGlyphs, tempGlyph)
totalWidth += tempGlyph.surface.W + padding
if tempGlyph.surface.H > maxHeight {
maxHeight = tempGlyph.surface.H
}
}
atlasSurface, err := sdl.CreateRGBSurface(0, totalWidth, maxHeight, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000)
if err != nil {
return fmt.Errorf("could not create atlas surface: %w", err)
}
defer atlasSurface.Free()
fontMap := &FontMap{
Glyphs: make(map[rune]Glyph),
padding: padding,
}
var currentX int32 = 0
for _, tempGlyph := range tempGlyphs {
dstRect := sdl.Rect{X: currentX, Y: 0, W: tempGlyph.surface.W, H: tempGlyph.surface.H}
if err := tempGlyph.surface.Blit(nil, atlasSurface, &dstRect); err != nil {
tempGlyph.surface.Free()
continue
}
fontMap.Glyphs[tempGlyph.Rune] = Glyph{
Rune: tempGlyph.Rune,
Advance: tempGlyph.Advance,
SrcRect: dstRect,
}
currentX += tempGlyph.surface.W + padding
tempGlyph.surface.Free()
}
textureID, err := graphics.CreateTextureFromSurface(atlasSurface)
if err != nil {
return fmt.Errorf("could not create texture from atlas surface: %w", err)
}
fontMap.FontMapTextureID = textureID
fontMap.Width = atlasSurface.W
fontMap.Height = atlasSurface.H
for r, g := range fontMap.Glyphs {
g.TextureID = textureID
fontMap.Glyphs[r] = g
}
font.Map = fontMap
return nil
}
// createTempGlyph renders a single character to a temporary SDL surface.
func (fm *FontManager) createTempGlyph(font *FontEntry, char rune) (TempGlyph, error) {
runeSurface, err := font.Font.RenderGlyphBlended(char, sdl.Color{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return TempGlyph{}, fmt.Errorf("unable to create glyph for char '%c': %w", char, err)
}
metrics, err := font.Font.GlyphMetrics(char)
if err != nil {
runeSurface.Free()
return TempGlyph{}, fmt.Errorf("unable to fetch glyph metrics for char '%c': %w", char, err)
}
return TempGlyph{
Rune: char,
Advance: metrics.Advance,
surface: runeSurface,
}, nil
}
// GetFont retrieves a loaded font by its name.
func (fm *FontManager) GetFont(name string) (*FontEntry, bool) {
fontEntry, ok := fm.Fonts[name]
return fontEntry, ok
}
// GetGlyph retrieves the rendering data for a specific character from a font's map.
func (fm *FontManager) GetGlyph(fontName string, r rune) (Glyph, bool) {
fontEntry, ok := fm.Fonts[fontName]
if !ok || fontEntry.Map == nil {
return Glyph{}, false
}
glyph, ok := fontEntry.Map.Glyphs[r]
return glyph, ok
}
// GetFontNames returns a slice of all loaded font names.
func (fm *FontManager) GetFontNames() []string {
names := make([]string, 0, len(fm.Fonts))
for name := range fm.Fonts {
names = append(names, name)
}
return names
}
// Close frees all loaded font resources, including the generated textures.
func (fm *FontManager) Close() {
for _, fontEntry := range fm.Fonts {
if fontEntry.Map != nil && fontEntry.Map.FontMapTextureID != 0 {
gl.DeleteTextures(1, &fontEntry.Map.FontMapTextureID)
}
if fontEntry.Font != nil {
fontEntry.Font.Close()
}
}
if fm.stringFBO != 0 {
gl.DeleteFramebuffers(1, &fm.stringFBO)
}
if fm.stringVAO != 0 {
gl.DeleteVertexArrays(1, &fm.stringVAO)
}
if fm.stringVBO != 0 {
gl.DeleteBuffers(1, &fm.stringVBO)
}
}
func (fm *FontManager) CreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width int32, height int32, err error) {
shaderID := shader.ProgramID
if fontName == "" || text == "" {
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
}
selectedFont, ok := fm.Fonts[fontName]
if !ok || selectedFont.Map == nil {
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
}
var totalWidth, maxHeight int32
glyphsToRender := make([]Glyph, 0, len(text))
for _, r := range text {
g, ok := selectedFont.Map.Glyphs[r]
if !ok {
g = selectedFont.Map.Glyphs['?'] // Fallback
}
totalWidth += g.SrcRect.W + selectedFont.Map.padding
if g.SrcRect.H > maxHeight {
maxHeight = g.SrcRect.H
}
glyphsToRender = append(glyphsToRender, g)
}
if totalWidth <= 0 || maxHeight <= 0 {
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
}
var lastFBO int32
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
var lastViewport [4]int32
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
var newTextureID uint32
gl.GenTextures(1, &newTextureID)
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
gl.DeleteTextures(1, &newTextureID)
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
}
gl.Viewport(0, 0, totalWidth, maxHeight)
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
gl.Clear(gl.COLOR_BUFFER_BIT)
gl.UseProgram(shaderID)
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
if shader.LocTextColor != -1 {
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
}
gl.ActiveTexture(gl.TEXTURE0)
gl.Uniform1i(shader.LocTexture, 0)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
gl.BindVertexArray(fm.stringVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
var currentX float32
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
for _, glyph := range glyphsToRender {
xPos := currentX
yPos := float32(0)
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
u0 := float32(glyph.SrcRect.X) / atlasW
v0 := float32(glyph.SrcRect.Y) / atlasH
u1 := u0 + w/atlasW
v1 := v0 + h/atlasH
vertices := []float32{
xPos, yPos + h, u0, v0,
xPos, yPos, u0, v1,
xPos + w, yPos, u1, v1,
xPos, yPos + h, u0, v0,
xPos + w, yPos, u1, v1,
xPos + w, yPos + h, u1, v0,
}
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
gl.DrawArrays(gl.TRIANGLES, 0, 6)
currentX += float32(glyph.Advance)
}
gl.BindVertexArray(0)
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
gl.Disable(gl.BLEND)
return newTextureID, totalWidth, maxHeight, nil
}
// NewCreateStringTexture does the same thing as CreateStringTexture, except that it returns the width in pixels used by the glyphs
// in the texture, rather than the width of the texture itself. This can be used for trimming.
func (fm *FontManager) NewCreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width float32, height int32, err error) {
shaderID := shader.ProgramID
if fontName == "" || text == "" {
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
}
selectedFont, ok := fm.Fonts[fontName]
if !ok || selectedFont.Map == nil {
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
}
var totalWidth, maxHeight int32
glyphsToRender := make([]Glyph, 0, len(text))
for _, r := range text {
g, ok := selectedFont.Map.Glyphs[r]
if !ok {
g = selectedFont.Map.Glyphs['?'] // Fallback
}
totalWidth += g.SrcRect.W + selectedFont.Map.padding
if g.SrcRect.H > maxHeight {
maxHeight = g.SrcRect.H
}
glyphsToRender = append(glyphsToRender, g)
}
if totalWidth <= 0 || maxHeight <= 0 {
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
}
var lastFBO int32
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
var lastViewport [4]int32
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
var newTextureID uint32
gl.GenTextures(1, &newTextureID)
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
gl.DeleteTextures(1, &newTextureID)
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
}
gl.Viewport(0, 0, totalWidth, maxHeight)
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
gl.Clear(gl.COLOR_BUFFER_BIT)
gl.UseProgram(shaderID)
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
if shader.LocTextColor != -1 {
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
}
gl.ActiveTexture(gl.TEXTURE0)
gl.Uniform1i(shader.LocTexture, 0)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
gl.BindVertexArray(fm.stringVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
var currentX float32
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
for _, glyph := range glyphsToRender {
xPos := currentX
yPos := float32(0)
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
u0 := float32(glyph.SrcRect.X) / atlasW
v0 := float32(glyph.SrcRect.Y) / atlasH
u1 := u0 + w/atlasW
v1 := v0 + h/atlasH
vertices := []float32{
xPos, yPos + h, u0, v0,
xPos, yPos, u0, v1,
xPos + w, yPos, u1, v1,
xPos, yPos + h, u0, v0,
xPos + w, yPos, u1, v1,
xPos + w, yPos + h, u1, v0,
}
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
gl.DrawArrays(gl.TRIANGLES, 0, 6)
currentX += float32(glyph.Advance)
}
usedWidth := float32(currentX)
gl.BindVertexArray(0)
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
gl.Disable(gl.BLEND)
return newTextureID, usedWidth, maxHeight, nil
}
+128
View File
@@ -0,0 +1,128 @@
package graphics
import (
"fmt"
"log"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
)
// CreateTextureFromSurface uploads an SDL surface to an OpenGL texture and returns the texture ID.
func CreateTextureFromSurface(surface *sdl.Surface) (uint32, error) {
if surface == nil {
return 0, fmt.Errorf("CreateTextureFromSurface: input surface is nil")
}
var textureID uint32
var err error
surfaceToUpload := surface
var convertedSurface *sdl.Surface = nil
sdlFormatEnum := surface.Format.Format
var dataFormat uint32
var internalFormat int32 = gl.RGBA8
if sdlFormatEnum == sdl.PIXELFORMAT_INDEX8 {
convertedSurface, err = surface.ConvertFormat(sdl.PIXELFORMAT_ARGB8888, 0)
if err != nil {
return 0, fmt.Errorf("failed to convert INDEX8 surface to ARGB8888: %w", err)
}
defer convertedSurface.Free()
surfaceToUpload = convertedSurface
sdlFormatEnum = surfaceToUpload.Format.Format
}
switch sdlFormatEnum {
case sdl.PIXELFORMAT_ARGB8888:
dataFormat = gl.BGRA
case sdl.PIXELFORMAT_ABGR8888:
dataFormat = gl.RGBA
case sdl.PIXELFORMAT_RGBA8888:
dataFormat = gl.RGBA
case sdl.PIXELFORMAT_BGRA8888:
dataFormat = gl.BGRA
default:
return 0, fmt.Errorf("unhandled SDL pixel format for OpenGL upload: %s", sdl.GetPixelFormatName(uint(sdlFormatEnum)))
}
for errCode := gl.GetError(); errCode != gl.NO_ERROR; errCode = gl.GetError() {
log.Printf("OpenGL error pending before GenTextures: 0x%X", errCode)
}
gl.GenTextures(1, &textureID)
if errCode := gl.GetError(); errCode != gl.NO_ERROR {
return 0, fmt.Errorf("OpenGL error 0x%X after GenTextures", errCode)
}
if textureID == 0 {
return 0, fmt.Errorf("gl.GenTextures returned textureID 0")
}
gl.BindTexture(gl.TEXTURE_2D, textureID)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
expectedPitch := surfaceToUpload.W * int32(surfaceToUpload.Format.BytesPerPixel)
if int32(surfaceToUpload.Pitch) != expectedPitch {
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, surfaceToUpload.Pitch/int32(surfaceToUpload.Format.BytesPerPixel))
} else {
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
}
gl.TexImage2D(
gl.TEXTURE_2D,
0,
internalFormat,
surfaceToUpload.W,
surfaceToUpload.H,
0,
dataFormat,
gl.UNSIGNED_BYTE,
gl.Ptr(surfaceToUpload.Pixels()),
)
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 4)
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
if glErr := gl.GetError(); glErr != gl.NO_ERROR {
gl.DeleteTextures(1, &textureID)
return 0, fmt.Errorf("OpenGL error 0x%X after TexImage2D", glErr)
}
gl.BindTexture(gl.TEXTURE_2D, 0)
return textureID, nil
}
// CheckGLError queries OpenGL for errors and returns the first one found.
func CheckGLError() error {
errCode := gl.GetError()
if errCode == gl.NO_ERROR {
return nil
}
switch errCode {
case gl.INVALID_ENUM:
return fmt.Errorf("INVALID_ENUM")
case gl.INVALID_VALUE:
return fmt.Errorf("INVALID_VALUE")
case gl.INVALID_OPERATION:
return fmt.Errorf("INVALID_OPERATION")
case gl.STACK_OVERFLOW:
return fmt.Errorf("STACK_OVERFLOW")
case gl.STACK_UNDERFLOW:
return fmt.Errorf("STACK_UNDERFLOW")
case gl.OUT_OF_MEMORY:
return fmt.Errorf("OUT_OF_MEMORY")
case gl.INVALID_FRAMEBUFFER_OPERATION:
return fmt.Errorf("INVALID_FRAMEBUFFER_OPERATION")
default:
return fmt.Errorf("Unknown GL error: 0x%X", errCode)
}
}
+193
View File
@@ -0,0 +1,193 @@
package graphics
import (
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
)
const (
VirtualWidth int32 = 1920
VirtualHeight int32 = 1080
)
var (
vfbo uint32
vfboTexture uint32
vfboDepthStencil uint32
blitVAO uint32
blitVBO uint32
blitShaderProg uint32
nativeW, nativeH int32
)
// InitVirtualFramebuffer creates the FBO at virtual resolution and the blit shader.
// Call after OpenGL context is created.
func InitVirtualFramebuffer(displayW, displayH int32) error {
nativeW = displayW
nativeH = displayH
// --- FBO setup ---
gl.GenFramebuffers(1, &vfbo)
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
gl.GenTextures(1, &vfboTexture)
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, VirtualWidth, VirtualHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, vfboTexture, 0)
// Depth/stencil renderbuffer
gl.GenRenderbuffers(1, &vfboDepthStencil)
gl.BindRenderbuffer(gl.RENDERBUFFER, vfboDepthStencil)
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, VirtualWidth, VirtualHeight)
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, vfboDepthStencil)
if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FRAMEBUFFER_COMPLETE {
return fmt.Errorf("virtual framebuffer incomplete: 0x%X", status)
}
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
// --- Fullscreen quad ---
vertices := []float32{
// pos (x,y) texcoord (u,v)
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, 1, 1, 1,
}
gl.GenVertexArrays(1, &blitVAO)
gl.GenBuffers(1, &blitVBO)
gl.BindVertexArray(blitVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, blitVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
gl.BindVertexArray(0)
// --- Blit shader ---
var err error
blitShaderProg, err = compileBlitShader()
if err != nil {
return fmt.Errorf("blit shader compilation failed: %w", err)
}
return nil
}
// BeginVirtualRender binds the FBO so all subsequent draws go to the virtual 1080p buffer.
func BeginVirtualRender() {
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
gl.Viewport(0, 0, VirtualWidth, VirtualHeight)
}
// BlitToScreen draws the virtual buffer to the actual screen at native resolution.
func BlitToScreen() {
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
gl.Viewport(0, 0, nativeW, nativeH)
gl.ClearColor(0, 0, 0, 1)
gl.Clear(gl.COLOR_BUFFER_BIT)
gl.UseProgram(blitShaderProg)
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
gl.BindVertexArray(blitVAO)
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.BindVertexArray(0)
}
// DestroyVirtualFramebuffer cleans up all FBO resources.
func DestroyVirtualFramebuffer() {
if blitShaderProg != 0 {
gl.DeleteProgram(blitShaderProg)
}
if blitVBO != 0 {
gl.DeleteBuffers(1, &blitVBO)
}
if blitVAO != 0 {
gl.DeleteVertexArrays(1, &blitVAO)
}
if vfboDepthStencil != 0 {
gl.DeleteRenderbuffers(1, &vfboDepthStencil)
}
if vfboTexture != 0 {
gl.DeleteTextures(1, &vfboTexture)
}
if vfbo != 0 {
gl.DeleteFramebuffers(1, &vfbo)
}
}
func compileBlitShader() (uint32, error) {
vertSrc := `#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
void main() {
vTexCoord = aTexCoord;
gl_Position = vec4(aPos, 0.0, 1.0);
}` + "\x00"
fragSrc := `#version 330 core
in vec2 vTexCoord;
out vec4 FragColor;
uniform sampler2D screenTexture;
void main() {
FragColor = texture(screenTexture, vTexCoord);
}` + "\x00"
vertShader, err := compileShader(vertSrc, gl.VERTEX_SHADER)
if err != nil {
return 0, err
}
fragShader, err := compileShader(fragSrc, gl.FRAGMENT_SHADER)
if err != nil {
return 0, err
}
prog := gl.CreateProgram()
gl.AttachShader(prog, vertShader)
gl.AttachShader(prog, fragShader)
gl.LinkProgram(prog)
var status int32
gl.GetProgramiv(prog, gl.LINK_STATUS, &status)
if status == gl.FALSE {
var logLen int32
gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
logStr := make([]byte, logLen)
gl.GetProgramInfoLog(prog, logLen, nil, &logStr[0])
return 0, fmt.Errorf("shader link error: %s", string(logStr))
}
gl.DeleteShader(vertShader)
gl.DeleteShader(fragShader)
return prog, nil
}
func compileShader(src string, shaderType uint32) (uint32, error) {
shader := gl.CreateShader(shaderType)
csrc, free := gl.Strs(src)
gl.ShaderSource(shader, 1, csrc, nil)
free()
gl.CompileShader(shader)
var status int32
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
if status == gl.FALSE {
var logLen int32
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLen)
logStr := make([]byte, logLen)
gl.GetShaderInfoLog(shader, logLen, nil, &logStr[0])
return 0, fmt.Errorf("shader compile error: %s", string(logStr))
}
return shader, nil
}
+79
View File
@@ -0,0 +1,79 @@
// package keybinds provides keybind functionality for the entire application
package keybinds
import (
"github.com/veandco/go-sdl2/sdl"
)
// KeyCombo represents a combination of a key and it's active modifier.
type KeyCombo struct {
Key sdl.Keycode
Mod sdl.Keymod
}
// Binds contains all currently registered Keybinds.
var Binds = make(map[KeyCombo]func())
// Enabled controls whether PerformAction dispatches keybinds. Set to false
// to temporarily suppress all global keybinds (e.g. during a modal page).
var Enabled = true
// Regiser registers a new keybind in Binds
func Register(k sdl.Keycode, m sdl.Keymod, action func()) {
mod := NormalizeModifiers(m)
newCombo := KeyCombo{k, mod}
_, ok := Binds[newCombo]
if ok {
}
Binds[newCombo] = action
}
// BindKey adds a new KeyCombo and Function (the action to take) to the keybinds.Binds
func BindKey(k KeyCombo, f func()) {
k.Mod = NormalizeModifiers(k.Mod)
Binds[k] = f
}
// UnbindKey removes a KeyCombo from the available binds
func UnbindKey(k KeyCombo) {
k.Mod = NormalizeModifiers(k.Mod)
delete(Binds, k)
}
func PerformAction(event sdl.Event) {
if !Enabled {
return
}
switch e := event.(type) {
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN {
key := e.Keysym.Sym
rawMod := e.Keysym.Mod
mod := NormalizeModifiers(sdl.Keymod(rawMod))
combo := KeyCombo{key, mod}
action, ok := Binds[combo]
if ok {
action()
}
}
}
}
// NormalizeModifiers converts a raw SDL modifier bitmask into a canonical form
// focusing on common modifiers (SHIFT, CTRL, ALT, GUI) and ignoring others like CapsLock.
func NormalizeModifiers(mod sdl.Keymod) sdl.Keymod {
var normalized sdl.Keymod = sdl.KMOD_NONE
if mod&sdl.KMOD_SHIFT != 0 {
normalized |= sdl.KMOD_SHIFT
}
if mod&sdl.KMOD_CTRL != 0 {
normalized |= sdl.KMOD_CTRL
}
if mod&sdl.KMOD_ALT != 0 {
normalized |= sdl.KMOD_ALT
}
if mod&sdl.KMOD_GUI != 0 {
normalized |= sdl.KMOD_GUI
}
return normalized
}
+73
View File
@@ -0,0 +1,73 @@
package license
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"time"
)
var Level int
type Validator struct {
client *http.Client
apiURL string
apiPath string
}
type Response struct {
Key string `json:"key"`
Email string `json:"email"`
Active bool `json:"active"`
Type string `json:"type"`
Level string `json:"level"`
LastCheck time.Time `json:"lastCheck"`
NewCheck time.Time `json:"newCheck"`
}
func NewValidator(apiURL, apiPath string) *Validator {
return &Validator{
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: true,
},
},
apiURL: apiURL,
apiPath: apiPath,
}
}
func (v *Validator) Validate(key, email string) (*Response, error) {
u, err := url.Parse(v.apiURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, v.apiPath)
q := u.Query()
q.Set("licenseKey", key)
q.Set("emailAddress", email)
u.RawQuery = q.Encode()
resp, err := v.client.Get(u.String())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %w", err)
}
var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed ot decode response: %w", err)
}
return &result, nil
}
+138
View File
@@ -0,0 +1,138 @@
package logger
import (
"fmt"
"log"
"os"
"sync"
"time"
"gorm.io/gorm"
)
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarning
LevelError
LevelFatal
)
func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
}
type logEntry struct {
timestamp string
level string
message string
}
var (
mu sync.Mutex
db *gorm.DB
file *os.File
console = log.New(os.Stderr, "", 0)
dbChan chan logEntry
dbChanOnce sync.Once
)
// startDBWriter starts a single background goroutine to drain log entries to the database.
func startDBWriter() {
dbChan = make(chan logEntry, 256)
go func() {
for entry := range dbChan {
row := map[string]any{
"timestamp": entry.timestamp,
"level": entry.level,
"message": entry.message,
}
if err := db.Table("log_entries").Create(row).Error; err != nil {
mu.Lock()
console.Printf("Failed to write log to database: %v", err)
mu.Unlock()
}
}
}()
}
// AddFile attaches or replaces the file backend
func AddFile(path string) error {
mu.Lock()
defer mu.Unlock()
if file != nil {
file.Close()
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
file = f
return nil
}
// AddDatabase attaches or replaces the database backend
func AddDatabase(d *gorm.DB) {
mu.Lock()
defer mu.Unlock()
db = d
dbChanOnce.Do(startDBWriter)
}
// Close shuts down the database writer goroutine and closes the file backend.
func Close() error {
mu.Lock()
defer mu.Unlock()
// Stop the DB writer goroutine by closing the channel
if dbChan != nil {
close(dbChan)
dbChan = nil
db = nil
}
if file != nil {
err := file.Close()
file = nil
return err
}
return nil
}
func logMsg(level Level, format string, args ...any) {
message := fmt.Sprintf(format, args...)
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
line := fmt.Sprintf("%s [%s] %s", timestamp, level, message)
mu.Lock()
console.Println(line)
if file != nil {
fmt.Fprintln(file, line)
}
if db != nil && dbChan != nil { // both cleared by Close()
select {
case dbChan <- logEntry{timestamp: timestamp, level: level.String(), message: message}:
default:
// Channel full, drop the DB write rather than blocking
}
}
mu.Unlock()
}
func Debug(format string, args ...any) { logMsg(LevelDebug, format, args...) }
func Info(format string, args ...any) { logMsg(LevelInfo, format, args...) }
func Warning(format string, args ...any) { logMsg(LevelWarning, format, args...) }
func Error(format string, args ...any) { logMsg(LevelError, format, args...) }
func Fatal(format string, args ...any) {
logMsg(LevelFatal, format, args...)
time.Sleep(100 * time.Millisecond)
os.Exit(1)
}
+346
View File
@@ -0,0 +1,346 @@
// internal/audio/mixer/mixer.go
package mixer
import (
"errors"
"fmt"
"strconv"
"sync"
"github.com/veandco/go-sdl2/mix"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/logger"
)
var (
mu sync.Mutex
inited bool
deviceOpen bool
deviceName string
currentMusic *mix.Music
currentPath string
currentLoops int // 0 = once, -1 = forever
currentVolume int // 0..128, defaults to MAX_VOLUME
// Config knobs (could be made public or read from your config)
defaultFreq = 48000
defaultFormat = uint16(sdl.AUDIO_F32)
defaultChannels = 2
defaultChunkSize = 1024
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
)
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(wantedDevice string) error {
mu.Lock()
defer mu.Unlock()
if inited {
return nil
}
// Let SDL choose the best audio driver for the system
// Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc.
// Users can set SDL_AUDIODRIVER environment variable if needed
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
return fmt.Errorf("SDL audio init failed: %w", err)
}
// WAV-only does not require mix.Init(); add when you support OGG/MP3/etc.
if err := openDeviceLocked(wantedDevice); err != nil {
// Clean up the subsystem on failure so callers can retry later.
sdl.QuitSubSystem(sdl.INIT_AUDIO)
return err
}
// Check if the volume is stored in the config; if not, default to max.
storedVolume, err := db.GetConfigValue("last_volume")
if err != nil {
return fmt.Errorf("retrieving last_volume from config: %w", err)
}
lastVolume, err := strconv.Atoi(storedVolume)
if err != nil {
return fmt.Errorf("parsing last_volume %q: %w", storedVolume, err)
}
if lastVolume < 0 {
lastVolume = int(mix.MAX_VOLUME)
currentVolume = lastVolume
if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
}
} else {
currentVolume = lastVolume
}
// mix.VolumeMusic(currentVolume)
inited = true
return nil
}
// openDeviceLocked tries to open the named device; if it fails and the name wasn't empty, falls back to default.
func openDeviceLocked(name string) error {
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
if name != "" {
// Log the failure of the requested device
// Fallback to default device
if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil {
return fmt.Errorf("failed to open device %q and default device: %w", name, err2)
}
deviceName = "" // actually opened default
} else {
return fmt.Errorf("failed to open default audio device: %w", err)
}
} else {
if name != "" {
} else {
}
deviceName = name
}
deviceOpen = true
return nil
}
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
// If something is already playing, it stops and replaces it.
func Play(path string, loopForever bool) error {
if !db.AudioEnabled() {
return nil
}
mu.Lock()
defer mu.Unlock()
if !inited || !deviceOpen {
return errors.New("audio mixer not initialized")
}
if path == "" {
return errors.New("empty audio path")
}
// Stop and free any prior music
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
m, err := mix.LoadMUS(path)
if err != nil {
return fmt.Errorf("LoadMUS(%q): %w", path, err)
}
currentMusic = m
currentPath = path
if loopForever {
currentLoops = -1
} else {
currentLoops = 0
}
// Open the device
if err := openDeviceLocked(deviceName); err != nil {
logger.Error("Failed to open device: %w", err)
}
// Apply remembered volume
mix.VolumeMusic(currentVolume)
if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free()
currentMusic = nil
return fmt.Errorf("music.Play: %w", err)
}
return nil
}
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
// It also frees the currently loaded music (so a new Play will reload).
func Stop(fadeMs int) {
if !db.AudioEnabled() {
return
}
mu.Lock()
defer mu.Unlock()
if !inited {
return
}
if fadeMs > 0 && mix.PlayingMusic() {
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below
}
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
currentPath = ""
currentLoops = 0
}
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
func SetVolume128(increment int) {
if !db.AudioEnabled() {
return
}
mu.Lock()
defer mu.Unlock()
currentVolume += increment
if currentVolume < 0 {
currentVolume = 0
} else if currentVolume > mix.MAX_VOLUME {
currentVolume = int(mix.MAX_VOLUME)
}
// Store in config
if err := db.SetConfigValue("last_volume", strconv.Itoa(currentVolume)); err != nil {
}
// Apply to SDL_mixer
if inited && deviceOpen {
mix.VolumeMusic(currentVolume)
}
}
// IsPlaying reports whether music is currently playing (or fading).
func IsPlaying() bool {
mu.Lock()
defer mu.Unlock()
if !inited {
return false
}
return mix.PlayingMusic()
}
// ListDevices returns the list of available output device names (from core SDL).
func ListDevices() []string {
mu.Lock()
defer mu.Unlock()
n := sdl.GetNumAudioDevices(false)
if n <= 0 {
return nil
}
out := make([]string, 0, n)
for i := 0; i < n; i++ {
if name := sdl.GetAudioDeviceName(i, false); name != "" {
out = append(out, name)
}
}
return out
}
// CurrentDevice returns the last requested/opened device name ("" means default).
func CurrentDevice() string {
mu.Lock()
defer mu.Unlock()
return deviceName
}
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
func SwitchDevice(newName string) error {
mu.Lock()
defer mu.Unlock()
if !inited {
return errors.New("audio mixer not initialized")
}
// Log device switch attempt
oldDevice := deviceName
if oldDevice == "" {
oldDevice = "system default"
}
newDeviceLog := newName
if newDeviceLog == "" {
newDeviceLog = "system default"
}
// Capture current state
wasPlaying := mix.PlayingMusic()
savedPath := currentPath
savedLoops := currentLoops
savedVolume := currentVolume
// Stop and release current music before reopening device
if wasPlaying {
}
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
currentPath = ""
currentLoops = 0
// Close and reopen device
if deviceOpen {
mix.CloseAudio()
deviceOpen = false
}
if err := openDeviceLocked(newName); err != nil {
// Best effort: try to reopen the previous device (or default) so we don't leave audio dead
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
} else {
}
return err
}
// Restore volume
mix.VolumeMusic(savedVolume)
// Optionally resume previous track if there was one
if wasPlaying && savedPath != "" {
m, err := mix.LoadMUS(savedPath)
if err != nil {
// Don't fail the device switch just because reload failed
return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err)
}
currentMusic = m
currentPath = savedPath
currentLoops = savedLoops
if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free()
currentMusic = nil
currentPath = ""
currentLoops = 0
return fmt.Errorf("device switched, but restarting playback failed: %w", err)
}
}
return nil
}
func GetVolume128() int {
mu.Lock()
defer mu.Unlock()
return currentVolume
}
// Shutdown fully tears everything down. Call at app exit.
func Shutdown() {
mu.Lock()
defer mu.Unlock()
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
if deviceOpen {
mix.CloseAudio()
deviceOpen = false
}
// If you add mix.Init(decoderFlags) in the future, pair it with mix.Quit() here.
if inited {
sdl.QuitSubSystem(sdl.INIT_AUDIO)
inited = false
}
}
+428
View File
@@ -0,0 +1,428 @@
// Package network provides a high-level utility for managing network
// connections via the system's D-Bus and NetworkManager.
package network
import (
"errors"
"fmt"
"log"
"net"
"sort"
"strings"
"time"
"github.com/godbus/dbus/v5"
"github.com/google/uuid"
)
// --- D-Bus NetworkManager Constants ---
const (
nmService = "org.freedesktop.NetworkManager"
nmPath = "/org/freedesktop/NetworkManager"
nmInterface = "org.freedesktop.NetworkManager"
nmDeviceInterface = "org.freedesktop.NetworkManager.Device"
nmWirelessDeviceInterface = "org.freedesktop.NetworkManager.Device.Wireless"
nmAPInterface = "org.freedesktop.NetworkManager.AccessPoint"
nmDeviceTypeEthernet uint32 = 1
nmDeviceTypeWifi uint32 = 2
nmDeviceStateActivated uint32 = 100
APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY
APSecKeyMgmtEAP uint32 = 0x00000020
APSecKeyMgmtPSK uint32 = 0x00000100
APSecKeyMgmtOWE uint32 = 0x00000200
APSecKeyMgmtSAE uint32 = 0x00000400
)
// ErrScanAborted is returned when a scan is cancelled via the stop channel.
var ErrScanAborted = errors.New("network scan aborted by caller")
// Status defines the type for various connection status codes.
type Status int
const (
StatusError Status = iota
StatusNoDevicesFound
StatusWifiAvailableNotConnected
StatusWifiConnectedNoInternet
StatusWifiConnectedInternetUp
StatusEthernetNotConnected
StatusEthernetConnectedNoInternet
StatusEthernetConnectedInternetUp
)
// ConnectionStatus holds the detailed result of a connectivity check.
type ConnectionStatus struct {
StatusCode Status
Message string
}
// DeviceType defines the kind of network device.
type DeviceType int
const (
TypeUnknown DeviceType = iota
TypeEthernet
TypeWifi
)
// Device holds information about a single network device.
type Device struct {
Path dbus.ObjectPath
InterfaceName string
Type DeviceType
State uint32
}
// Manager handles all D-Bus communication with NetworkManager.
type Manager struct {
conn *dbus.Conn
nmObject dbus.BusObject
Devices []Device
}
// AccessPoint holds essential information about a discovered Wi-Fi network.
type AccessPoint struct {
ObjectPath dbus.ObjectPath
SSID string
Strength byte
IsProtected bool
IsActive bool
WpaFlags uint32
RsnFlags uint32
}
// New creates a new network Manager and connects to the system D-Bus.
func New() (*Manager, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
}
nmObject := conn.Object(nmService, nmPath)
return &Manager{conn: conn, nmObject: nmObject}, nil
}
// Conn returns the underlying dbus.Conn for advanced queries.
func (m *Manager) Conn() *dbus.Conn {
return m.conn
}
// Close disconnects from the D-Bus.
func (m *Manager) Close() {
if m.conn != nil {
m.conn.Close()
}
}
// FindDevices scans the system for all available network devices (Ethernet and Wi-Fi).
func (m *Manager) FindDevices() error {
var devicePaths []dbus.ObjectPath
call := m.nmObject.Call(nmInterface+".GetDevices", 0)
if call.Err != nil {
return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err)
}
if err := call.Store(&devicePaths); err != nil {
return fmt.Errorf("could not parse device list: %w", err)
}
m.Devices = []Device{}
for _, path := range devicePaths {
deviceObj := m.conn.Object(nmService, path)
typeVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".DeviceType")
if err != nil {
log.Printf("network: Skipping device %s, could not get DeviceType: %v", path, err)
continue
}
deviceType, _ := typeVariant.Value().(uint32)
var newDevice Device
switch deviceType {
case nmDeviceTypeEthernet:
newDevice.Type = TypeEthernet
case nmDeviceTypeWifi:
newDevice.Type = TypeWifi
default:
continue
}
newDevice.Path = path
ifaceVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".Interface")
newDevice.InterfaceName, _ = ifaceVariant.Value().(string)
stateVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".State")
newDevice.State, _ = stateVariant.Value().(uint32)
m.Devices = append(m.Devices, newDevice)
}
return nil
}
// CheckInternetConnection performs a comprehensive check for an active internet connection.
func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) {
if err := m.FindDevices(); err != nil {
return &ConnectionStatus{StatusError, "Failed to scan for network devices"}, err
}
if len(m.Devices) == 0 {
return &ConnectionStatus{StatusNoDevicesFound, "No network hardware found."}, nil
}
hasWifiAdapter := false
hasEthernetAdapter := false
for _, device := range m.Devices {
if device.Type == TypeEthernet {
hasEthernetAdapter = true
if device.State == nmDeviceStateActivated {
if pingTest() {
return &ConnectionStatus{StatusEthernetConnectedInternetUp, "Ethernet connected with internet access."}, nil
}
return &ConnectionStatus{StatusEthernetConnectedNoInternet, "Ethernet connected, but no internet access."}, nil
}
}
}
for _, device := range m.Devices {
if device.Type == TypeWifi {
hasWifiAdapter = true
if device.State == nmDeviceStateActivated {
if pingTest() {
return &ConnectionStatus{StatusWifiConnectedInternetUp, "Wi-Fi connected with internet access."}, nil
}
return &ConnectionStatus{StatusWifiConnectedNoInternet, "Wi-Fi connected, but no internet access."}, nil
}
}
}
if hasWifiAdapter {
return &ConnectionStatus{StatusWifiAvailableNotConnected, "Wi-Fi adapter is available but not connected."}, nil
}
if hasEthernetAdapter {
return &ConnectionStatus{StatusEthernetNotConnected, "Ethernet adapter is available but not connected."}, nil
}
return &ConnectionStatus{StatusNoDevicesFound, "No recognizable network hardware found."}, nil
}
// StartScan performs a Wi-Fi scan asynchronously. The optional stopChan can be used to abort.
func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- error, stopChan <-chan struct{}) {
var wifiDevice *Device
for i := range m.Devices {
if m.Devices[i].Type == TypeWifi {
wifiDevice = &m.Devices[i]
break
}
}
if wifiDevice == nil {
errorChan <- fmt.Errorf("no Wi-Fi device available for scanning")
return
}
// Ensure Wi-Fi is enabled
nmObj := m.conn.Object(nmService, nmPath)
prop, err := nmObj.GetProperty(nmInterface + ".WirelessEnabled")
if err != nil {
errorChan <- fmt.Errorf("could not read WirelessEnabled property: %w", err)
return
}
if !prop.Value().(bool) {
call := nmObj.Call("org.freedesktop.DBus.Properties.Set", 0,
nmInterface,
"WirelessEnabled",
dbus.MakeVariant(true),
)
if call.Err != nil {
errorChan <- fmt.Errorf("could not enable Wi-Fi: %w", call.Err)
return
}
time.Sleep(2 * time.Second) // Give NetworkManager time to bring up the device
}
// Request a scan
wifiDeviceWireless := m.conn.Object(nmService, wifiDevice.Path)
call := wifiDeviceWireless.Call(nmWirelessDeviceInterface+".RequestScan", 0, make(map[string]dbus.Variant))
if call.Err != nil {
errorChan <- fmt.Errorf("scan request failed: %w", call.Err)
return
}
select {
case <-time.After(3 * time.Second):
case <-stopChan:
errorChan <- ErrScanAborted
return
}
var apPaths []dbus.ObjectPath
call = wifiDeviceWireless.Call(nmWirelessDeviceInterface+".GetAllAccessPoints", 0)
if call.Err != nil {
errorChan <- fmt.Errorf("could not retrieve network list: %w", call.Err)
return
}
if err := call.Store(&apPaths); err != nil {
errorChan <- fmt.Errorf("could not parse network list: %w", err)
return
}
processedAPs, err := m.scanAccessPoints(apPaths)
if err != nil {
errorChan <- err
return
}
resultsChan <- processedAPs
}
// scanAccessPoints filters and sorts the raw list of D-Bus AP paths.
func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, error) {
tempNetworkMap := make(map[string]AccessPoint)
for _, apPath := range apPaths {
apObj := m.conn.Object(nmService, apPath)
props, err := m.getAllProperties(apObj, nmAPInterface)
if err != nil {
log.Printf("network: Failed to get properties for AP %s: %v", apPath, err)
continue
}
ssidBytes, _ := props["Ssid"].Value().([]byte)
if len(ssidBytes) == 0 {
continue
}
ssidStr := string(ssidBytes)
strength, _ := props["Strength"].Value().(byte)
flags, _ := props["Flags"].Value().(uint32)
wpaFlags, _ := props["WpaFlags"].Value().(uint32)
rsnFlags, _ := props["RsnFlags"].Value().(uint32)
isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0)
currentAP := AccessPoint{
ObjectPath: apPath,
SSID: ssidStr,
Strength: strength,
IsProtected: isProtected,
WpaFlags: wpaFlags,
RsnFlags: rsnFlags,
}
if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength {
tempNetworkMap[ssidStr] = currentAP
}
}
networkList := make([]AccessPoint, 0, len(tempNetworkMap))
for _, ap := range tempNetworkMap {
networkList = append(networkList, ap)
}
sort.Slice(networkList, func(i, j int) bool {
if networkList[i].Strength != networkList[j].Strength {
return networkList[i].Strength > networkList[j].Strength
}
return strings.ToLower(networkList[i].SSID) < strings.ToLower(networkList[j].SSID)
})
return networkList, nil
}
// Connect attempts to connect a Wi-Fi device to a given access point.
func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) error {
if wifiDevice.Type != TypeWifi {
return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device")
}
settings := map[string]map[string]dbus.Variant{
"connection": {
"type": dbus.MakeVariant("802-11-wireless"),
"uuid": dbus.MakeVariant(uuid.NewString()),
"id": dbus.MakeVariant(ap.SSID),
"interface-name": dbus.MakeVariant(wifiDevice.InterfaceName),
},
"802-11-wireless": {
"ssid": dbus.MakeVariant([]byte(ap.SSID)),
"mode": dbus.MakeVariant("infrastructure"),
},
}
if ap.IsProtected {
settings["802-11-wireless"]["security"] = dbus.MakeVariant("802-11-wireless-security")
secSettings := make(map[string]dbus.Variant)
// Default to WPA-PSK, but override with more specific types if found.
secSettings["key-mgmt"] = dbus.MakeVariant("wpa-psk")
if (ap.RsnFlags & APSecKeyMgmtSAE) != 0 {
secSettings["key-mgmt"] = dbus.MakeVariant("sae") // WPA3
} else if (ap.RsnFlags&APSecKeyMgmtEAP) != 0 || (ap.WpaFlags&APSecKeyMgmtEAP) != 0 {
log.Println("network: Enterprise AP (EAP) detected. Full configuration may be required.")
secSettings["key-mgmt"] = dbus.MakeVariant("wpa-eap")
}
secSettings["psk"] = dbus.MakeVariant(passphrase)
settings["802-11-wireless-security"] = secSettings
}
call := m.nmObject.Call(nmInterface+".AddAndActivateConnection", 0, settings, wifiDevice.Path, ap.ObjectPath)
if call.Err != nil {
return fmt.Errorf("AddAndActivateConnection D-Bus call error: %w", call.Err)
}
return nil
}
// pingTest performs a simple DNS lookup to check for internet connectivity.
func pingTest() bool {
_, err := net.LookupHost("www.google.com")
return err == nil
}
// getAllProperties is a helper to fetch all properties for a D-Bus object interface.
func (m *Manager) getAllProperties(obj dbus.BusObject, iface string) (map[string]dbus.Variant, error) {
var props map[string]dbus.Variant
call := obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface)
if call.Err != nil {
return nil, call.Err
}
if err := call.Store(&props); err != nil {
return nil, err
}
return props, nil
}
// WaitForInternet blocks until CheckInternetConnection reports Internet up,
// or until maxWait elapses. When it first sees "up", it requires that state
// to be stable for at least 'stableFor' before returning success to avoid flapping.
func (m *Manager) WaitForInternet(maxWait, stableFor time.Duration) (*ConnectionStatus, error) {
deadline := time.Now().Add(maxWait)
var lastErr error
var lastStatus *ConnectionStatus
var upSince time.Time
// Reasonable poll cadence; NM typically completes within a few seconds.
const poll = 500 * time.Millisecond
for time.Now().Before(deadline) {
st, err := m.CheckInternetConnection()
if err != nil {
lastErr = err
lastStatus = &ConnectionStatus{StatusError, "NetworkManager not ready"}
upSince = time.Time{} // reset stability
} else {
lastStatus = st
switch st.StatusCode {
case StatusEthernetConnectedInternetUp, StatusWifiConnectedInternetUp:
if upSince.IsZero() {
upSince = time.Now()
}
if time.Since(upSince) >= stableFor {
return st, nil // stable "up"
}
default:
// not up; keep waiting
upSince = time.Time{}
}
}
time.Sleep(poll)
}
// Timed out; return the last observed status and error (if any).
if lastStatus == nil {
lastStatus = &ConnectionStatus{StatusError, "Timed out waiting for connectivity"}
}
if lastErr != nil {
return lastStatus, fmt.Errorf("timeout waiting for internet: %w", lastErr)
}
return lastStatus, nil
}
+273
View File
@@ -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
}
+276
View File
@@ -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
}
+395
View File
@@ -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()
}
+458
View File
@@ -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()
}
+251
View File
@@ -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()
}
+198
View File
@@ -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 }
+83
View File
@@ -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
}
+243
View File
@@ -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
}
+697
View File
@@ -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
}
}
}
+253
View File
@@ -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
}
+388
View File
@@ -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 &amp; &lt; &gt; &quot; &#39; 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, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
// 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()
}
+196
View File
@@ -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
}
+439
View File
@@ -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()
}
+790
View File
@@ -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()
}
+100
View File
@@ -0,0 +1,100 @@
package pattern
import (
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
)
type ColorPattern struct {
Colors []sdl.Color
colorIndex int
repeatsLeft int
a, b int
increasing bool
}
func NewColorPattern(colors []sdl.Color) *ColorPattern {
if len(colors) < 2 {
panic("ColorPattern requires at least 2 colors")
}
return &ColorPattern{
Colors: colors,
a: 0,
b: 1,
increasing: true,
repeatsLeft: 1, // Start with b (1)
}
}
// ATTENTION: TED
//
// # Fibonacci-Oscillating Temporal Color Distribution for Sequential Text Elements
//
// This method distributes intent-based colors across sequential text elements (words) using an
// oscillating Fibonacci sequence to control temporal color duration, creating rhythmic color
// patterns that maintain visual balance while varying exposure times.
//
// The technology addresses the challenge of applying multiple psychologically-selected colors
// to scrolling text in a way that maintains even distribution over time while creating natural
// rhythm. Conventional approaches either apply colors randomly (creating unbalanced temporal
// distribution) or use fixed intervals (producing mechanical, unnatural patterns that lack
// harmonic variation). Our method ensures each color in the palette appears throughout the
// entire display duration while varying its temporal presence according to natural mathematical
// progression.
//
// The approach uses a bidirectional Fibonacci sequence to determine how many consecutive words
// receive each color. The sequence increases (1, 1, 2, 3, 5, 8, 13, 21, 34, 55 words) then
// decreases back down (55, 34, 21, 13, 8, 5, 3, 2, 1, 1 words), creating an oscillating pattern.
// Colors from the intent-based palette are applied in sequence, with each color's duration
// determined by the current Fibonacci value. This creates a "breathing" rhythm where colors
// appear briefly, expand to longer durations, then contract back to brief appearances before
// cycling to the next color.
//
// Key differentiators from existing solutions:
// - Uses bidirectional Fibonacci sequence (ascending then descending) rather than fixed intervals
// - Creates natural rhythmic variation in color exposure duration
// - Ensures all palette colors appear throughout entire display timeline, not in isolated sections
// - Combines intent-based color selection with mathematical temporal distribution
// - Produces harmonic color rhythm synchronized with natural numerical progression
//
// Within our Rhema Wave generation system, this color distribution works in conjunction with
// the spatially-scrambled character patterns. While the scrambling creates spatial frequency
// signatures, the Fibonacci-oscillating color pattern creates temporal frequency modulation.
// The colors themselves are selected according to color theory principles based on the proclamation's
// psychological intent, and the oscillating Fibonacci distribution ensures these therapeutic colors
// maintain presence throughout the viewing session while creating harmonic temporal rhythm that
// reinforces the overall Rhema Wave effect.
func (c *ColorPattern) Color() sdl.Color {
// 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418
// This is a failsafe in case this pattern is used but colors are not supplied. Without this check, no text will display due to not having a color
// and becoming translucent.
if len(c.Colors) == 0 {
return colors.White // fallback to white
}
color := c.Colors[c.colorIndex%len(c.Colors)]
c.repeatsLeft--
if c.repeatsLeft <= 0 {
// Move to next color in the palette
c.colorIndex = (c.colorIndex + 1) % len(c.Colors)
// Update Fibonacci sequence for repeat count
if c.increasing {
c.a, c.b = c.b, c.a+c.b
if c.b > 55 { // Update this threahold to adjust the number of repeats
c.increasing = false
}
} else {
c.a, c.b = c.b-c.a, c.a
if c.a <= 0 {
c.a, c.b = 0, 1
c.increasing = true
}
}
c.repeatsLeft = c.b
}
return color
}
+127
View File
@@ -0,0 +1,127 @@
package pattern
type FibonacciPattern struct {
masterSequence []int
sequence []int
increasing bool
}
func init() {
Register("fibonacci", func() LinePattern {
return &FibonacciPattern{
masterSequence: []int{1, 2, 3, 5, 8, 13, 21},
sequence: []int{1},
increasing: true,
}
})
}
func (f *FibonacciPattern) Name() string { return "Fibonacci" }
// ATTENTION: TED
//
// # Fibonacci-Oscillating Spatial Distribution for Sequential Text Display
//
// This method arranges sequential text elements (words from proclamations) into display lines
// where the word count per line follows an oscillating Fibonacci sequence, creating both a
// visual wave pattern in two-dimensional space and ensuring even temporal distribution of
// content segments.
//
// The technology addresses the challenge of displaying repeated proclamation content in a way
// that maintains balanced exposure of all content portions while creating visually dynamic
// spatial patterns. Conventional scrolling text approaches either use fixed line lengths
// (creating uniform, non-dynamic displays) or random word counts (producing unbalanced content
// distribution where some portions appear more frequently than others). Our method ensures
// proclamation content is consumed in progressively sized segments that balance over time while
// creating a natural wave-like visual rhythm.
//
// The approach uses a bidirectional Fibonacci sequence to determine words per line. Starting
// with minimal word count (1, 1 words), the sequence expands (2, 3, 5, 8, 13, 21... words per
// line) until reaching a threshold, then contracts back down (21, 13, 8, 5, 3, 2, 1, 1 words),
// creating an oscillating wave. As lines scroll vertically, this varying horizontal word count
// creates a lateral wave pattern in the two-dimensional display space. Words are drawn sequentially
// from the proclamation text, and the varying segment sizes (1 word, then 2, then 3, then 5, etc.)
// ensure that over multiple oscillation cycles, the entire proclamation content receives balanced
// temporal exposure.
//
// Key differentiators from existing solutions:
// - Uses oscillating Fibonacci sequence for line word counts rather than fixed or random lengths
// - Creates two-dimensional wave pattern from one-dimensional text sequence
// - Ensures mathematical content distribution balance over time through progressive segmentation
// - Produces natural expansion-contraction rhythm synchronized with Fibonacci progression
// - Combines spatial wave formation with temporal content distribution in single mechanism
//
// Within our Rhema Wave generation system, this spatial distribution pattern works in concert
// with the character-level scrambling and color distribution methods. While character scrambling
// creates local spatial frequency patterns and color oscillation creates temporal color rhythm,
// the Fibonacci line length pattern generates a macro-level spatial wave that modulates the
// overall display geometry. The mathematical relationship between line lengths creates harmonic
// spatial progression, and the oscillating nature ensures proclamation content cycles through
// varying exposure durations, contributing to the layered frequency composition of the complete
// Rhema Wave output.
func (f *FibonacciPattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
if ctx == nil || len(words) == 0 {
return nil
}
numWords := f.sequence[len(f.sequence)-1]
targetWidth := int32(ctx.ScreenWidth)
var line []string
var currentWidth int32
wordsAdded := 0
for wordsAdded < numWords && len(words) > 0 {
if int(*wordIndex) > len(words) {
*wordIndex = 0
}
word := words[*wordIndex%len(words)]
*wordIndex++
if word == "" {
continue
}
wordWidth := estimateWordWidth(word, ctx.Font, ctx.FontScale)
if len(line) > 0 {
wordWidth += ctx.SpaceWidth
}
if len(line) == 0 || currentWidth+wordWidth <= int32(float32(targetWidth)*float32(.9)) {
line = append(line, word)
currentWidth += wordWidth
wordsAdded++
} else {
break
}
}
// TODO: What should we do here instead?
// A line being longer than the display still needs to be handled, though, it should never happen
// if currentWidth >= ctx.ScreenWidth {
// syscall.Kill(syscall.Getpid(), syscall.SIGTRAP)
// }
// Update the sequence based on direction
if f.increasing {
if len(f.sequence) < len(f.masterSequence) {
// Grow sequence by appending next Fibonacci number
f.sequence = append(f.sequence, f.masterSequence[len(f.sequence)])
} else {
// Reached the top, now start shrinking
f.increasing = false
f.sequence = f.sequence[:len(f.sequence)-1]
}
} else {
if len(f.sequence) > 1 {
// Shrink the sequence
f.sequence = f.sequence[:len(f.sequence)-1]
} else {
// Hit bottom, start growing again
f.increasing = true
f.sequence = append(f.sequence, f.masterSequence[1]) // index 1 = second element = 2
}
}
return line
}
+71
View File
@@ -0,0 +1,71 @@
package pattern
import (
"fmt"
"rhemawavetech.com/rhemawave/internal/fontManager"
)
type LinePattern interface {
GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string // Generates the next line in the pattern
Name() string // Used to display the name of a LinePattern
}
// PatternInfo holds the public information about a registered pattern.
type PatternInfo struct {
Key string // The machine-readable key (e.g., "sine")
DisplayName string // The human-readable name for the UI (e.g., "Sine Wave")
}
type LineRenderContext struct {
ScreenWidth int32
Font *fontManager.FontEntry
FontScale float32
SpaceWidth int32
}
var Registry = make(map[string]func() LinePattern)
func Register(name string, constructor func() LinePattern) {
Registry[name] = constructor
}
func Get(name string) (LinePattern, error) {
constructor, ok := Registry[name]
if !ok {
return nil, fmt.Errorf("failed to find line pattern %s in Registry", name)
}
return constructor(), nil
}
// ListAvailable returns a slice of info for all registered patterns.
// This is used to build a settings menu.
func ListAvailable() []PatternInfo {
var list []PatternInfo
for key, constructor := range Registry {
pattern := constructor() // Create a temporary instance to get its name
list = append(list, PatternInfo{
Key: key,
DisplayName: pattern.Name(),
})
}
return list
}
// estimateWordWidth provides a basic estimate of the width of a word based on the font and scale.
func estimateWordWidth(word string, font *fontManager.FontEntry, scale float32) int32 {
if font == nil {
// Basic fallback estimate
return int32(float32(len(word)) * 10 * scale)
}
total := int32(0)
for _, ch := range word {
glyph, ok := fontManager.Get().GetGlyph(font.Name, ch)
if !ok {
continue
}
total += int32(float32(glyph.Advance) * scale)
}
return total
}
+80
View File
@@ -0,0 +1,80 @@
package pattern
import (
"math"
"rhemawavetech.com/rhemawave/internal/fontManager"
)
type SineWavePattern struct {
waveStepCounter int
}
func init() {
Register("sine", func() LinePattern { return &SineWavePattern{} })
}
func (s *SineWavePattern) Name() string { return "Sine Wave" }
func (s *SineWavePattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
const (
totalLines = 67
frequency = 5.0 // cycles across all lines
amplitude = 0.85 // how much wave can scale
baseRatio = 0.03 // minimum line width as % of screen
sharpness = 3.0 // sharpness of the curve
)
screenWidth := int32(800)
spaceWidth := int32(10)
font := (*fontManager.FontEntry)(nil)
fontScale := float32(1.0)
if ctx != nil {
screenWidth = ctx.ScreenWidth
spaceWidth = ctx.SpaceWidth
font = ctx.Font
fontScale = ctx.FontScale
}
// Sharpen the wave (skinnier troughs)
// raw := (math.Sin(2*math.Pi*frequency*float64(s.waveStepCounter)/totalLines) + 1.0) / 2.0
// waveVal := math.Pow(raw, sharpness)
angle := 2 * math.Pi * frequency * float64(s.waveStepCounter) / totalLines
waveVal := (math.Sin(angle) + 1.0) / 2.0
// Target width in pixels
targetWidth := int32(float64(screenWidth) * (baseRatio + amplitude*waveVal))
var line []string
var currentWidth int32
for len(words) > 0 {
word := words[*wordIndex%len(words)]
*wordIndex++
if word == "" {
continue
}
wordWidth := estimateWordWidth(word, font, fontScale)
if len(line) > 0 {
wordWidth += spaceWidth
}
if len(line) == 0 {
// Always include the first word
line = append(line, word)
currentWidth += wordWidth
} else {
// Only add next word if it fits
if currentWidth+wordWidth > targetWidth {
break
}
line = append(line, word)
currentWidth += wordWidth
}
}
s.waveStepCounter++
return line
}
+206
View File
@@ -0,0 +1,206 @@
package shaderManager
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/go-gl/gl/v3.3-core/gl"
"rhemawavetech.com/rhemawave/internal/db"
)
// Shader represents a compiled GLSL program and its associated data.
type Shader struct {
Name string
ProgramID uint32
Render func() // This will be used in the future.
// Cached uniform locations (looked up once after linking)
LocMVPMatrix int32
LocTexture int32
LocTextColor int32
LocColor int32
}
// ShaderManager handles the loading, compiling, and management of GLSL shader programs.
type ShaderManager struct {
shaders map[string]*Shader
}
var (
instance *ShaderManager
once sync.Once
)
// InitShaderManager initializes the ShaderManager singleton.
func InitShaderManager() error {
var err error
once.Do(func() {
sm := &ShaderManager{
shaders: make(map[string]*Shader),
}
if e := sm.LoadShaders(); e != nil {
err = fmt.Errorf("could not load shaders: %w", e)
return
}
instance = sm
})
return err
}
// Get returns the singleton ShaderManager instance.
func Get() *ShaderManager {
if instance == nil {
panic("ShaderManager has not been initialized. Call InitShaderManager at application startup.")
}
return instance
}
// Get retrieves a compiled Shader object by its name.
func (sm *ShaderManager) Get(name string) (*Shader, bool) {
shader, ok := sm.shaders[name]
return shader, ok
}
// ListShaders returns all shaders.
func (sm *ShaderManager) ListShaders() map[string]*Shader {
return sm.shaders
}
// Close deletes all loaded shader programs from the GPU to free up resources.
func (sm *ShaderManager) Close() {
for name, shader := range sm.shaders {
gl.DeleteProgram(shader.ProgramID)
delete(sm.shaders, name)
}
}
// LoadShaders scans a directory for .vert and .frag files and creates a shader program for each pair.
func (sm *ShaderManager) LoadShaders() error {
// Get assets directory from database
assetsDir, err := db.GetConfigValue("assets_dir")
if err != nil {
return fmt.Errorf("retrieving assets_dir from db: %w", err)
}
srcDir := filepath.Join(assetsDir, "shaders")
shaderFiles := make(map[string]string)
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".vert") {
name := strings.TrimSuffix(info.Name(), ".vert")
shaderFiles[name] = path
}
return nil
})
if err != nil {
return fmt.Errorf("error walking shader directory: %w", err)
}
for name, vertPath := range shaderFiles {
fragPath := strings.Replace(vertPath, ".vert", ".frag", 1)
if _, err := os.Stat(fragPath); os.IsNotExist(err) {
continue
}
programID, err := createShaderProgram(vertPath, fragPath)
if err != nil {
continue
}
newShader := &Shader{
Name: name,
ProgramID: programID,
Render: nil, // Render function is not yet assigned.
}
newShader.cacheUniformLocations()
sm.shaders[name] = newShader
}
return nil
}
// cacheUniformLocations looks up common uniform locations once and stores them on the Shader.
func (s *Shader) cacheUniformLocations() {
s.LocMVPMatrix = gl.GetUniformLocation(s.ProgramID, gl.Str("u_mvpMatrix\x00"))
s.LocTexture = gl.GetUniformLocation(s.ProgramID, gl.Str("u_texture\x00"))
s.LocTextColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_textColor\x00"))
s.LocColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_color\x00"))
}
// createShaderProgram reads, compiles, and links a vertex and fragment shader from file paths.
func createShaderProgram(vertexPath, fragmentPath string) (uint32, error) {
vertexSource, err := os.ReadFile(vertexPath)
if err != nil {
return 0, fmt.Errorf("reading vertex shader %s: %w", vertexPath, err)
}
fragmentSource, err := os.ReadFile(fragmentPath)
if err != nil {
return 0, fmt.Errorf("reading fragment shader %s: %w", fragmentPath, err)
}
vertexShader, err := compileShader(string(vertexSource)+"\x00", gl.VERTEX_SHADER)
if err != nil {
return 0, err
}
defer gl.DeleteShader(vertexShader)
fragmentShader, err := compileShader(string(fragmentSource)+"\x00", gl.FRAGMENT_SHADER)
if err != nil {
return 0, err
}
defer gl.DeleteShader(fragmentShader)
program := gl.CreateProgram()
gl.AttachShader(program, vertexShader)
gl.AttachShader(program, fragmentShader)
gl.LinkProgram(program)
var status int32
gl.GetProgramiv(program, gl.LINK_STATUS, &status)
if status == gl.FALSE {
var logLength int32
gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength)
log := strings.Repeat("\x00", int(logLength+1))
gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log))
return 0, fmt.Errorf("failed to link program: %v", log)
}
return program, nil
}
// compileShader is a helper function that compiles a single shader source string.
func compileShader(source string, shaderType uint32) (uint32, error) {
shader := gl.CreateShader(shaderType)
csources, free := gl.Strs(source)
gl.ShaderSource(shader, 1, csources, nil)
free()
gl.CompileShader(shader)
var status int32
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
if status == gl.FALSE {
var logLength int32
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength)
log := strings.Repeat("\x00", int(logLength+1))
gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))
var shaderTypeName string
switch shaderType {
case gl.VERTEX_SHADER:
shaderTypeName = "Vertex"
case gl.FRAGMENT_SHADER:
shaderTypeName = "Fragment"
default:
shaderTypeName = "Unknown"
}
return 0, fmt.Errorf("failed to compile %s shader: %v", shaderTypeName, log)
}
return shader, nil
}