initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
package application
import (
"errors"
"os"
"path/filepath"
"time"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
"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"
"rhemawavetech.com/rhemawave/internal/sync"
)
const (
targetFPS = 60.0
openGLMajorVersion = 3
openGLMinorVersion = 3
// volumeStepSize = 16
// apiURL = "https://portal.rhemawavetech.com/api/v1"
)
type State int
const (
FIRST_RUN State = iota
NEEDS_INTERNET
NEEDS_LICENSE_KEY
NEEDS_EMAIL_ADDRESS
DOWNLOADING_ASSETS
READY
)
type Application struct {
Window *sdl.Window
License *license.Response
pageStack []page.Page
pendingAction func()
running bool
DB *gorm.DB
PageRouter *page.Router
state State
downloadsComplete bool
downloadFailed bool
}
var lastNetworkCheckTime time.Time
var networkIsUp bool
// ----------------------------------------------------------------------------
// Main Entry Point
// ----------------------------------------------------------------------------
func Run() {
var err error
var homeDir string
var dataDir string
var DB *gorm.DB
networkIsUp = false
logger.Info("Starting Application")
homeDir, err = os.UserHomeDir()
if err != nil {
logger.Error("unable to fetch user $HOME directory: %v", err)
return
}
dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave")
if err := os.MkdirAll(dataDir, 0755); err != nil {
logger.Error("unable to create data directory %s %v", dataDir, err)
return
}
testFile := filepath.Join(dataDir, ".access_test")
f, err := os.Create(testFile)
if err != nil {
logger.Error("data %s is not writeable: %v", dataDir, err)
return
}
f.Close()
os.Remove(testFile)
logger.AddFile(filepath.Join(dataDir, "log.txt"))
logger.SetLevel = logger.LevelInfo
DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
if err != nil {
logger.Error("application failed to connect to the database: %v", err)
return
}
if err = db.Migrate(DB); err != nil {
logger.Error("application failed to perform database migrations: %v", err)
return
}
firstRun := false
// Populate the initial Config cache OR create defaults
cfg, err := db.GetConfig(DB)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
firstRun = true
cfg = &db.AppConfig{
Model: gorm.Model{ID: 1},
DataDir: dataDir,
AssetsDir: dataDir,
SystemLevel: "BASIC",
SelectedAudioDevice: "default",
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
AudioEnabled: true,
SavedVolume: 1.0,
}
if err := DB.Create(cfg).Error; err != nil {
logger.Error("failed to create config: %v", err)
return
}
}
}
logger.AddDatabase(DB)
app := &Application{
DB: DB,
}
logger.Debug("==== Debug Statement ====")
if firstRun {
app.state = FIRST_RUN
}
w, h := app.initializeSDLAndOpenGL()
defer app.cleanup()
app.initializeManagers()
app.initializeAudio()
// Create a buffered channel so log writes don't slow down downloads.
downloadProgressChan := make(chan download.DownloadStatus, 1000)
downloadDone := make(chan struct{})
app.PageRouter = page.NewRouter(page.PageCtx{
DbPtr: app.DB,
W: w,
H: h,
MessageChannel: downloadProgressChan,
DownloadDone: downloadDone,
})
// Sync the proclamations from the API to the Database
sync.SyncProclamations(DB)
sync.SyncCategories(DB)
sync.SyncFrequencies(DB)
// audioFileNames := []string{
// "OriginalAudio.wav",
// "174Hz.wav",
// "396Hz.wav",
// "417Hz.wav",
// "432Hz.wav",
// "639Hz.wav",
// "741Hz.wav",
// "852Hz.wav",
// "963Hz.wav",
// "1111Hz.wav",
// "14Hz_Binaural.wav",
// "40Hz_Binaural.wav",
// "AlphaWaves.wav",
// "PinealGland.wav",
// }
audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
_, err = os.Stat(audioDir)
if os.IsNotExist(err) {
if err = os.MkdirAll(audioDir, 0755); err != nil {
logger.Error("unable to create audio directory: %v", err)
}
}
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/")
app.runEventLoop()
}
// computeState determines what state the application should be in based on
// current conditions, without performing any navigation. It returns the first
// unmet prerequisite in priority order: network > email > license > downloads > ready.
func (app *Application) computeState() State {
if app.state == READY {
return READY
}
// Check if downloads have finished (non-blocking)
if !app.downloadsComplete {
select {
case <-app.PageRouter.Context.DownloadDone:
app.downloadsComplete = true
default:
}
}
// Check if there is an internet connection
if !networkIsUp {
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
lastNetworkCheckTime = time.Now()
if !network.HasInternet() {
return NEEDS_INTERNET
}
networkIsUp = true // internet confirmed, don't check again
} else {
return app.state // throttled, can't determine yet
}
}
// Check for email address
config, err := db.GetConfig(app.DB)
if err != nil {
panic(err)
}
// A rather annoying, insecure, but sometimes necessary "print the config" statement
// data, err := json.MarshalIndent(config, "", " ")
// if err != nil {
// logger.Error("failed to marshal config: %v", err)
// } else {
// logger.Debug("AppConfig: %s", data)
// }
if config.EmailAddress == "" {
return NEEDS_EMAIL_ADDRESS
}
// Check for license key
config, err = db.GetConfig(app.DB)
if err != nil {
panic(err)
}
if config.LicenseKey == "" {
return NEEDS_LICENSE_KEY
}
// All config prerequisites met -- now wait for downloads to finish
if !app.downloadsComplete {
return DOWNLOADING_ASSETS
}
return READY
}
// checkState keeps watch of the application state and sets pages
// accordingly. This function runs once per frame.
func (app *Application) checkState() {
newState := app.computeState()
if newState == app.state {
return
}
oldState := app.state
app.state = newState
// Both DOWNLOADING_ASSETS and READY map to the HOME page.
// Skip re-navigation when transitioning between them to avoid
// destroying and recreating the Home page (which would close
// the already-closed DownloadDone channel).
oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY
newIsHome := newState == DOWNLOADING_ASSETS || newState == READY
if oldIsHome && newIsHome {
return
}
switch newState {
case NEEDS_INTERNET:
app.PageRouter.NavigateTo(page.WIFI)
case NEEDS_EMAIL_ADDRESS:
logger.Warning("No email address detected")
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
case NEEDS_LICENSE_KEY:
app.PageRouter.NavigateTo(page.LICENSE_KEY)
case DOWNLOADING_ASSETS, READY:
app.PageRouter.NavigateTo(page.HOME)
}
}
func (app *Application) initializeSDLAndOpenGL() (W, H int32) {
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")
return graphics.VirtualWidth, graphics.VirtualHeight
}
func (app *Application) initializeManagers() {
logger.Info("Initializing ShaderManager")
config, err := db.GetConfig(app.DB)
if err != nil {
panic(err)
}
shaderDir := filepath.Join(config.AssetsDir, "shaders")
logger.Info("Loading shaders from %s", shaderDir)
if err := shaderManager.InitShaderManager(shaderDir); err != nil {
logger.Fatal("ShaderManager initialization failed: %v", err)
}
logger.Info("Initializing FontManager")
if err := fontManager.InitFontManager(config); err != nil {
logger.Fatal("FontManager initialization failed: %v", err)
}
}
func (app *Application) initializeAudio() {
logger.Info("Initializing audio")
config, err := db.GetConfig(app.DB)
if err != nil {
panic(err)
}
if err := mixer.Init(config, app.refreshCache); err != nil {
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
logger.Error("Audio will be unavailable until a device is configured")
} else {
logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice)
}
}
func (app *Application) refreshCache() {}
func (app *Application) cleanup() {
logger.Info("Cleaning up")
if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
app.PageRouter.ActivePage.Destroy()
}
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 app.PageRouter == nil || app.PageRouter.ActivePage == nil {
logger.Fatal("No initial page loaded, Router 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.checkState()
app.handleEvents()
if !app.running {
break
}
deltaTime := float32(time.Since(lastTime).Seconds())
lastTime = time.Now()
app.updateCurrentPage(deltaTime)
app.renderFrame()
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.PageRouter.ActivePage; 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.PageRouter.ActivePage; 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.PageRouter.ActivePage; currentPage != nil {
currentPage.Render()
}
graphics.BlitToScreen()
app.Window.GLSwap()
}
// ----------------------------------------------------------------------------
// Keybind Registration
// ----------------------------------------------------------------------------
func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
if app.state != READY && app.state != DOWNLOADING_ASSETS {
return
}
app.PageRouter.NavigateTo(page.SETTINGS)
})
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
if app.state != READY && app.state != DOWNLOADING_ASSETS {
return
}
app.PageRouter.NavigateTo(page.SCROLLER)
})
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
if app.state != READY && app.state != DOWNLOADING_ASSETS {
return
}
app.PageRouter.NavigateTo(page.HOME)
})
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
app.Stop()
})
}
func (app *Application) Stop() {
app.running = false
}
func (app *Application) Quit() {
logger.Info("User has quit the application")
app.running = false
}
@@ -0,0 +1,475 @@
package application
import (
"testing"
"time"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/page"
)
// Regression: The app used to reach READY before audio downloads completed,
// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress.
// The fix gates READY on downloadsComplete being true.
// TestStateTransitionsToDownloadingAssets verifies that when all config
// prerequisites are met but downloads are not complete, the state transitions
// to DOWNLOADING_ASSETS instead of READY.
func TestStateTransitionsToDownloadingAssets(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", result)
}
}
// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state
// transitions to READY only after downloads are complete.
func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != READY {
t.Errorf("state = %v, want READY", result)
}
}
// Regression: Download failure should prevent the app from reaching READY.
// If downloads fail, the user sees an error and must restart to retry.
func TestDownloadFailurePreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
app.downloadFailed = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result == READY {
t.Error("state should not be READY when download has failed")
}
}
// TestStateStaysReadyOnceSet verifies that once the state reaches READY,
// computeState() returns READY without checking other prerequisites.
func TestStateStaysReadyOnceSet(t *testing.T) {
app := &Application{
state: READY,
DB: setupTestDB(t),
}
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != READY {
t.Errorf("state = %v, want READY (should not change once set)", result)
}
}
// TestEmailMissingPreventsReady verifies that missing email blocks progress
// even when network is up and downloads are complete.
func TestEmailMissingPreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = ""
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_EMAIL_ADDRESS {
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS", result)
}
}
// TestLicenseMissingPreventsReady verifies that missing license key blocks
// progress even when email and network are present.
func TestLicenseMissingPreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_LICENSE_KEY {
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
}
}
// TestDownloadDoneChannelSetsDownloadsComplete verifies that receiving on
// the DownloadDone channel sets downloadsComplete to true.
func TestDownloadDoneChannelSetsDownloadsComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
close(downloadDone)
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if !app.downloadsComplete {
t.Error("downloadsComplete should be true after DownloadDone channel is closed")
}
if result != READY {
t.Errorf("state = %v, want READY after downloads complete", result)
}
}
// TestPrerequisitePriorityEmailBeforeLicense verifies that missing email
// is reported before missing license key, even when both are absent.
func TestPrerequisitePriorityEmailBeforeLicense(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = ""
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_EMAIL_ADDRESS {
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS (email should be checked before license)", result)
}
}
// TestPrerequisitePriorityLicenseAfterEmail verifies that when email is
// present but license is missing, the state is NEEDS_LICENSE_KEY.
func TestPrerequisitePriorityLicenseAfterEmail(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_LICENSE_KEY {
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
}
}
// TestStateBlocksOnDownloadUntilChannelClosed verifies that computeState
// returns DOWNLOADING_ASSETS repeatedly until DownloadDone is closed.
func TestStateBlocksOnDownloadUntilChannelClosed(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// Should return DOWNLOADING_ASSETS multiple times before channel closes.
for i := 0; i < 5; i++ {
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Fatalf("iteration %d: state = %v, want DOWNLOADING_ASSETS", i, result)
}
}
// Now close the channel.
close(downloadDone)
result := app.computeState()
if result != READY {
t.Errorf("after close: state = %v, want READY", result)
}
}
// TestDownloadDoneNonBlocking verifies that computeState does not block
// when DownloadDone channel is open (non-blocking select).
func TestDownloadDoneNonBlocking(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// computeState must return promptly even though DownloadDone is not closed.
done := make(chan struct{})
go func() {
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Errorf("state = %v, want DOWNLOADING_ASSETS", result)
}
close(done)
}()
select {
case <-done:
// Good: returned promptly.
case <-time.After(2 * time.Second):
t.Fatal("computeState blocked on DownloadDone channel (should be non-blocking)")
}
}
// TestMultipleComputeStateCallsAfterDownloadComplete verifies that calling
// computeState repeatedly after downloads finish does not panic or change state.
func TestMultipleComputeStateCallsAfterDownloadComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
downloadDone := make(chan struct{})
close(downloadDone)
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// First call transitions to READY.
result := app.computeState()
if result != READY {
t.Fatalf("first call: state = %v, want READY", result)
}
app.state = result
// Subsequent calls should return READY without panic.
for i := 0; i < 10; i++ {
result = app.computeState()
if result != READY {
t.Fatalf("iteration %d: state = %v, want READY", i, result)
}
}
}
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := db.Connect(":memory:")
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
cfg := &db.AppConfig{
Model: gorm.Model{ID: 1},
DataDir: t.TempDir(),
AssetsDir: t.TempDir(),
SystemLevel: "BASIC",
SelectedAudioDevice: "default",
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
AudioEnabled: true,
SavedVolume: 1.0,
EmailAddress: "",
LicenseKey: "",
}
if err := d.Create(cfg).Error; err != nil {
t.Fatal(err)
}
return d
}
+214
View File
@@ -0,0 +1,214 @@
// 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}
DeepBloodRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
)
// Oranges
var (
LightOrange = sdl.Color{R: 255, G: 200, B: 127, A: 255}
Orange = sdl.Color{R: 255, G: 165, B: 0, A: 255}
OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255}
DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255}
DarkOrangeRed = sdl.Color{R: 255, G: 140, B: 105, A: 255}
Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255}
Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255}
PaleOrange = sdl.Color{R: 255, G: 223, B: 186, A: 255}
RedOrange = sdl.Color{R: 255, G: 69, 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}
MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255}
LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255}
LavenderGray = sdl.Color{R: 250, G: 240, B: 250, 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}
MintGreen = sdl.Color{R: 173, G: 255, B: 169, A: 255}
VeryPaleGreen = sdl.Color{R: 200, G: 255, B: 210, A: 255}
Honeydew = sdl.Color{R: 240, G: 255, B: 240, 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}
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}
RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, 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}
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}
MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255}
)
// Groups
// Based on Solfeggio Frequencies
var (
Freq_396_Colors = [7]sdl.Color{DeepBloodRed, Firebrick, Crimson, Tomato, DarkOrangeRed, Orange, PeachPuff}
Freq_417_Colors = [7]sdl.Color{RedOrange, Coral, DarkOrange, Orange, LightOrange, PaleOrange, BlanchedAlmond}
// Default proclamation needs to be using this
Freq_528_Colors = [7]sdl.Color{LimeGreen, MediumSeaGreen, LightGreen, PaleGreen, MintGreen, VeryPaleGreen, Honeydew}
Freq_639_Colors = [7]sdl.Color{SpringGreen, DarkTurquoise, Turquoise, CadetBlue, AquaMarine, PaleTurquoise, LightCyan}
Freq_741_Colors = [7]sdl.Color{DeepSkyBlue, DodgerBlue, RoyalBlue, CornflowerBlue, SkyBlue, LightBlue, AliceBlue}
Freq_852_Colors = [7]sdl.Color{Indigo, MediumPurple, BlueViolet, MediumSlateBlue, Orchid, Thistle, Lavender}
Freq_963_Colors = [7]sdl.Color{DarkViolet, MediumOrchid, MediumVioletRed, Plum, Violet, LavenderBlush, LavenderGray}
)
+112
View File
@@ -0,0 +1,112 @@
// Package db provides a database connection and functions for interacting with the database.
// The DB object should be held and maintained by the application, not by this package.
// The application should be responsible for connecting to, migrating, and initial population
// of the database. This package should only provide the schema definitions and helper functions.
// Schema definitions should clone API response structures as closely as possible.
package db
import (
"fmt"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const (
connectMaxAttempts = 5
connectRetryDelay = 500 * time.Millisecond
)
// Connect opens the SQLite database at dbpath with WAL mode and a hardened
// lock-recovery strategy suitable for kiosk use where the process may have
// crashed and left stale lock artefacts behind.
func Connect(dbpath string) (*gorm.DB, error) {
// ── Stale lock recovery ───────────────────────────────────────────────
// SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file.
// If the previous process crashed, the OS releases file locks but the
// artefacts remain. SQLite recovers them automatically on the next open,
// UNLESS an OS-level advisory lock is still held (rare but possible on
// Linux with certain crash patterns). Removing them before opening is
// safe: SQLite treats absent WAL/SHM files as a clean slate and will
// recreate them. We only do this if the main DB file already exists
// (i.e. this is not a first-run) to avoid masking legitimate errors.
// if _, err := os.Stat(dbpath); err == nil {
// for _, ext := range []string{"-wal", "-shm"} {
// artefact := dbpath + ext
// if _, err := os.Stat(artefact); err == nil {
// if removeErr := os.Remove(artefact); removeErr != nil {
// // Non-fatal: log and continue — SQLite may still recover.
// fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr)
// } else {
// fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact)
// }
// }
// }
// }
// ── Connection with retry ─────────────────────────────────────────────
// Even after removing WAL/SHM, the main DB file itself may still be
// briefly locked if another process is shutting down. Retry with
// backoff before giving up.
var (
db *gorm.DB
err error
)
for attempt := 1; attempt <= connectMaxAttempts; attempt++ {
db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
if err == nil {
break
}
if attempt == connectMaxAttempts {
return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err)
}
fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n",
attempt, connectMaxAttempts, err, connectRetryDelay)
time.Sleep(connectRetryDelay)
}
// ── Connection pool ───────────────────────────────────────────────────
// SQLite does not support concurrent writers. Setting MaxOpenConns(1)
// forces gorm to serialise all queries through a single connection,
// preventing "database is locked" errors from competing goroutines
// within this process. This is the single most important setting for
// SQLite in a multi-goroutine application.
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("could not get underlying sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
// ── Pragmas ───────────────────────────────────────────────────────────
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=FULL",
"PRAGMA foreign_keys=ON",
// 5 s gives the serialised connection time to finish a long write
// before returning SQLITE_BUSY to the application layer.
"PRAGMA busy_timeout=5000",
}
for _, pragma := range pragmas {
if _, err := sqlDB.Exec(pragma); err != nil {
return nil, fmt.Errorf("failed to set %q: %w", pragma, err)
}
}
return db, nil
}
// Migrate the database schema.
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(
&AppConfig{},
&Log{},
&Proclamation{},
&ProclamationCategory{},
&SolfeggioFrequency{},
&AudioFile{},
)
}
+58
View File
@@ -0,0 +1,58 @@
// schema.go defines the database schema for the GORM models.
package db
import "gorm.io/gorm"
type AppConfig struct {
gorm.Model
DataDir string
AssetsDir string
EmailAddress string
LicenseKey string
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
SelectedAudioDevice string // The pipewire device name
InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci"
AudioEnabled bool
SavedVolume float64 // Last saved volume as a percentage
FirstRun bool // Is this the first time the application has been run?
}
type Log struct {
gorm.Model
Timestamp int64
Level string
Message string
}
type Proclamation struct {
ID string `gorm:"primaryKey"`
Name string
Content string
SolfeggioFrequencyID int
CategoryID int
Selected bool
Available bool
Price float64
}
type ProclamationCategory struct {
ID int `gorm:"primaryKey"`
Name string
ParentId int
}
type SolfeggioFrequency struct {
ID int `gorm:"primaryKey"`
Name string
Value int
}
type AudioFile struct {
ID int `gorm:"primaryKey"`
Name string
Path string
Selected bool
Order int
}
+14
View File
@@ -0,0 +1,14 @@
package db
import "gorm.io/gorm"
// GetConfig fetches the application config from the database
func GetConfig(db *gorm.DB) (*AppConfig, error) {
var cfg AppConfig
err := db.First(&cfg, 1).Error
if err != nil {
return nil, err
}
return &cfg, nil
}
+239
View File
@@ -0,0 +1,239 @@
package download
import (
"bufio"
"crypto/md5"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"rhemawavetech.com/rhemawave/internal/logger"
)
// DownloadStatus holds information related to downloads and is
// the container for information returned via channels within this package.
type DownloadStatus struct {
Max int64
Cur int64
Msg string
Err error
}
// manifest maps filename → expected MD5 hex string
type manifest map[string]string
// ─── Server script to generate checksums.md5 ─────────────────────────────────
//
// Run this on your file server whenever you add or replace audio files:
//
// cd /path/to/audio && md5sum *.mp3 > checksums.md5
//
// That's it. The app will pick up changes automatically on next run.
// ─────────────────────────────────────────────────────────────────────────────
const manifestFilename = "checksums.md5"
// DownloadAudioFiles downloads audio files, skipping any that already exist
// locally with a matching MD5. Files with a wrong checksum are deleted and
// re-downloaded. Progress is sent to progressChan, which is closed on return.
func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) {
defer close(progressChan)
send := func(cur, max int64, msg string, err error) {
progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err}
}
send(0, 0, "Fetching file manifest...", nil)
m, err := fetchManifest(urlBase + manifestFilename)
if err != nil {
send(0, 0, "Failed to fetch manifest", err)
return
}
logger.Info("download: manifest loaded, %d files listed", len(m))
send(0, 0, "Comparing checksums...", nil)
type work struct {
filename string
expectedMD5 string
}
var queue []work
for filename, expectedMD5 := range m {
localPath := filepath.Join(destDir, filename)
localMD5, err := fileMD5(localPath)
if err != nil {
logger.Debug("download: %s not found locally, queuing", filename)
queue = append(queue, work{filename, expectedMD5})
continue
}
if localMD5 != expectedMD5 {
logger.Debug("download: %s checksum mismatch (got %s, want %s), re-downloading",
filename, localMD5, expectedMD5)
if err := os.Remove(localPath); err != nil {
logger.Warning("download: failed to remove corrupt file %s: %v", localPath, err)
}
queue = append(queue, work{filename, expectedMD5})
} else {
logger.Debug("download: %s OK", filename)
}
}
if len(queue) == 0 {
send(0, 0, "All files are up to date.", nil)
logger.Info("download: nothing to do")
return
}
total := len(queue)
send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil)
logger.Info("download: %d file(s) to download", total)
totalDownloaded := int64(0)
filesDownloaded := 0
for _, w := range queue {
url := urlBase + w.filename
destPath := filepath.Join(destDir, w.filename)
logger.Debug("download: starting %s", w.filename)
downloaded, err := downloadFile(url, destPath, func(n int64) {
totalDownloaded += n
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
})
if err != nil {
logger.Error("download: failed to download %s: %v", w.filename, err)
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
return
}
gotMD5, err := fileMD5(destPath)
if err != nil {
logger.Error("download: could not hash %s after download: %v", w.filename, err)
os.Remove(destPath)
send(totalDownloaded, 0, "Checksum verification failed", err)
return
}
if gotMD5 != w.expectedMD5 {
err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5)
logger.Error("download: %s %v", w.filename, err)
os.Remove(destPath)
send(totalDownloaded, 0, "Checksum verification failed — file removed", err)
return
}
filesDownloaded++
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
logger.Info("download: %s (%d bytes)", statusMsg, downloaded)
send(totalDownloaded, 0, statusMsg, nil)
}
send(totalDownloaded, totalDownloaded, "All downloads complete!", nil)
logger.Info("download: all files downloaded and verified")
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// fetchManifest downloads and parses a checksums.md5 file.
// Format per line: "<md5hex> <filename>" (two spaces, same as md5sum output)
func fetchManifest(url string) (manifest, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch manifest: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch manifest: server returned %s", resp.Status)
}
m := make(manifest)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// md5sum format: "hash filename" (two spaces)
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
logger.Warning("download: skipping malformed manifest line: %q", line)
continue
}
hash := strings.TrimSpace(parts[0])
name := strings.TrimSpace(parts[1])
m[name] = hash
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return m, nil
}
// fileMD5 computes the MD5 checksum of a local file.
// Returns an error if the file doesn't exist or can't be read.
func fileMD5(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hash %s: %w", path, err)
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// downloadFile streams url to destPath, calling onChunk(n) after each write
// with the number of bytes just written. Returns total bytes downloaded.
func downloadFile(url, destPath string, onChunk func(int64)) (int64, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("GET %s: %s", url, resp.Status)
}
out, err := os.Create(destPath)
if err != nil {
return 0, fmt.Errorf("create %s: %w", destPath, err)
}
defer out.Close()
var total int64
buf := make([]byte, 32*1024) // 32KB chunks
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
os.Remove(destPath)
return total, fmt.Errorf("write %s: %w", destPath, werr)
}
total += int64(n)
onChunk(int64(n))
}
if err == io.EOF {
break
}
if err != nil {
os.Remove(destPath)
return total, fmt.Errorf("read %s: %w", url, err)
}
}
return total, nil
}
+890
View File
@@ -0,0 +1,890 @@
package fontManager
import (
"fmt"
"os"
"path/filepath"
"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(cfg *db.AppConfig) 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),
}
fontPath := filepath.Join(cfg.AssetsDir, "fonts")
if err := fm.loadFonts(fontPath, cfg); 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 := "SpaceMonoNerdFont-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, cfg *db.AppConfig) 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)
baseFontSize := cfg.StandardFontSize
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)
// }
// Standard ASCII (A-Z, a-z, 0-9) + punctuation
chars := []rune{
// Numbers 0-9
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0-9
// Uppercase A-Z
65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, // A-O
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, // P-Z
// Lowercase a-z
97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // a-m
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, // n-z
// Basic punctuation (ASCII 32-47, 58-64, 91-96, 123-126)
32, // Space
33, // !
34, // "
35, // #
36, // $
37, // %
38, // &
39, // '
40, // (
41, // )
42, // *
43, // +
44, // ,
45, // -
46, // .
47, // /
58, // :
59, // ;
60, // <
61, // =
62, // >
63, // ?
64, // @
91, // [
92, // \
93, // ]
94, // ^
95, // _
96, // `
123, // {
124, // |
125, // }
126, // ~
// Common Unicode punctuation
161, // ¡
191, // ¿
8211, // –
8212, // —
8216, // ‘
8217, // ’
8220, // “
8221, // ”
8226, // •
8230, // …
8240, // ‰
8364, // €
162, // ¢
163, // £
165, // ¥
169, // ©
174, // ®
8482, // ™
232, // è
233, // é
10003, // checkmark
}
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)
}
}
@@ -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
}
+231
View File
@@ -0,0 +1,231 @@
package logger
import (
"fmt"
"log"
"os"
"time"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
)
// ─── Level ───────────────────────────────────────────────────────────────────
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarning
LevelError
LevelFatal
)
func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
}
// ─── Internal entry type ──────────────────────────────────────────────────────
type entry struct {
level Level
message string
timestamp time.Time
fatal bool
}
// ─── Package state ────────────────────────────────────────────────────────────
var (
SetLevel Level = LevelInfo
console = log.New(os.Stderr, "", 0)
ch chan entry // unbuffered send is a bug; always buffered
done chan struct{}
db_ *gorm.DB
file *os.File
)
func init() {
ch = make(chan entry, 4096)
done = make(chan struct{})
go drain()
}
// ─── Configuration ────────────────────────────────────────────────────────────
// AddFile attaches or replaces the file backend. Safe to call at any time.
func AddFile(path string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("logger: open file: %w", err)
}
// Send a sentinel that carries the new file handle.
// We do this through the channel so the drain goroutine owns
// all file state — no mutex needed.
fileSwapCh <- f
return nil
}
// AddDatabase attaches or replaces the database backend. Safe to call at any time.
func AddDatabase(d *gorm.DB) {
dbSwapCh <- d
}
// Close drains remaining entries and shuts down the logger.
// Call this with defer in main().
func Close() {
close(done)
<-drainDone // wait for drain goroutine to finish flushing
if file != nil {
file.Close()
file = nil
}
}
// ─── Swap channels (owned exclusively by drain goroutine) ────────────────────
var (
fileSwapCh = make(chan *os.File, 1)
dbSwapCh = make(chan *gorm.DB, 1)
drainDone = make(chan struct{})
)
// ─── Drain goroutine ──────────────────────────────────────────────────────────
//
// This is the ONLY goroutine that touches file, db_, or does DB writes.
// Everything else just sends to ch.
func drain() {
defer close(drainDone)
batch := make([]entry, 0, 64)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
writeBatchToDB(batch)
batch = batch[:0]
}
for {
select {
// New log entry
case e := <-ch:
writeToFile(e)
if db_ != nil {
batch = append(batch, e)
if len(batch) >= 64 {
flush()
}
}
// Periodic DB flush so low-volume logs don't sit in the batch forever
case <-ticker.C:
flush()
// Hot-swap the file backend
case f := <-fileSwapCh:
if file != nil {
file.Close()
}
file = f
// Hot-swap the DB backend
case d := <-dbSwapCh:
flush() // flush any pending entries to the old db first
db_ = d
console.Println("logger: database backend attached")
// Shutdown: drain what's left, then exit
case <-done:
// Pull any entries still sitting in the channel
for len(ch) > 0 {
e := <-ch
writeToFile(e)
batch = append(batch, e)
}
flush()
return
}
}
}
// ─── Writers (called only from drain goroutine) ───────────────────────────────
func writeToFile(e entry) {
line := formatLine(e)
if file != nil {
fmt.Fprintln(file, line)
}
}
func writeBatchToDB(batch []entry) {
if db_ == nil || len(batch) == 0 {
return
}
records := make([]db.Log, len(batch))
for i, e := range batch {
records[i] = db.Log{
Timestamp: e.timestamp.UnixMilli(),
Level: e.level.String(),
Message: e.message,
}
}
// Single INSERT ... VALUES (...), (...), (...) instead of N individual saves.
if result := db_.CreateInBatches(records, 64); result.Error != nil {
console.Printf("logger: db batch write failed: %v\n", result.Error)
}
}
func formatLine(e entry) string {
return fmt.Sprintf("%s [%s] %s",
e.timestamp.Format("2006-01-02 15:04:05.000"),
e.level,
e.message,
)
}
// ─── Public API ───────────────────────────────────────────────────────────────
func logMsg(level Level, fatal bool, format string, args ...any) {
if level == LevelDebug && SetLevel != LevelDebug {
return
}
e := entry{
level: level,
message: fmt.Sprintf(format, args...),
timestamp: time.Now(), // captured here, in the caller's goroutine
fatal: fatal,
}
// Console is synchronous — log.Logger is internally mutex-protected,
// so this is safe from any goroutine and gives you immediate output.
console.Println(formatLine(e))
// Non-blocking send: if the buffer is full, we drop rather than deadlock.
// 4096 entries of headroom means this should never happen in practice.
select {
case ch <- e:
default:
console.Println("logger: channel full, entry dropped")
}
if fatal {
// Give the drain goroutine a moment to flush before we exit.
// Close() is cleaner but Fatal has to exit immediately.
time.Sleep(150 * time.Millisecond)
os.Exit(1)
}
}
func Debug(format string, args ...any) { logMsg(LevelDebug, false, format, args...) }
func Info(format string, args ...any) { logMsg(LevelInfo, false, format, args...) }
func Warning(format string, args ...any) { logMsg(LevelWarning, false, format, args...) }
func Error(format string, args ...any) { logMsg(LevelError, false, format, args...) }
func Fatal(format string, args ...any) { logMsg(LevelFatal, true, format, args...) }
+496
View File
@@ -0,0 +1,496 @@
// internal/audio/mixer/mixer.go
package mixer
import (
"bufio"
"bytes"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"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
// 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
refreshCacheCallback func()
)
type Sink struct {
ID int
Name string
Volume float64
IsDefault bool
}
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(cfg *db.AppConfig, onUpdate func()) error {
mu.Lock()
defer mu.Unlock()
if inited {
return nil
}
refreshCacheCallback = onUpdate
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
return fmt.Errorf("SDL audio init failed: %w", err)
}
// FIX 1: Initialize MP3 and OGG decoders
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
if err != nil {
logger.Error("Failed to initialize MP3/OGG support: %v", err)
}
deviceName := cfg.SelectedAudioDevice
if deviceName == "default" {
deviceName = ""
}
if err := openDeviceLocked(deviceName); err != nil {
sdl.QuitSubSystem(sdl.INIT_AUDIO)
return err
}
SetAllSinkVolumes(cfg.SavedVolume)
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, cfg *db.AppConfig) error {
// Check if audio is enabled or disabled
if !cfg.AudioEnabled {
return nil
}
// Lock the mutex
mu.Lock()
defer mu.Unlock()
// Check if mixer has been initialized and that a device is open
if !inited {
logger.Error("audio mixer not initialized")
return errors.New("audio mixer not initialized")
}
if !deviceOpen {
if err := openDeviceLocked(""); err != nil {
return fmt.Errorf("failed to open audio device: %w", err)
}
}
if path == "" {
logger.Error("empty audio path")
return errors.New("empty audio path")
}
// Stop and free any prior music
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
// Load the music file
m, err := mix.LoadMUS(path)
if err != nil {
return fmt.Errorf("LoadMUS(%q): %w", path, err)
}
// Set current music to the loaded music file
currentMusic = m
currentPath = path
if loopForever {
currentLoops = -1
} else {
currentLoops = 0
}
mix.VolumeMusic(mix.MAX_VOLUME)
volume := mix.VolumeMusic(-1)
logger.Info("Current volume is %d", volume)
if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free()
currentMusic = nil
logger.Error("music.Play failed: %v", err)
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, cfg *db.AppConfig) {
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
}
// IsPlaying reports whether music is currently playing (or fading).
func IsPlaying() bool {
mu.Lock()
defer mu.Unlock()
if !inited {
return false
}
return mix.PlayingMusic()
}
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
func SetDefaultSinkVolume(level float64) error {
if level < 0 || level > 1 {
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
}
pct := fmt.Sprintf("%.2f", level)
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
}
return nil
}
// listSinkIDs returns just the numeric IDs of all audio sinks.
func listSinkIDs() ([]int, error) {
sinks, err := listSinks()
if err != nil {
return nil, err
}
ids := make([]int, len(sinks))
for i, s := range sinks {
ids[i] = s.ID
}
return ids, nil
}
// listSinks returns full Sink info for all audio sinks.
func listSinks() ([]Sink, error) {
out, err := exec.Command("wpctl", "status").Output()
if err != nil {
return nil, fmt.Errorf("wpctl status: %w", err)
}
return parseSinks(out)
}
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
func SetAllSinkVolumes(level float64) error {
ids, err := listSinkIDs()
if err != nil {
return err
}
pct := fmt.Sprintf("%.2f", level)
for _, id := range ids {
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
}
}
return nil
}
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
// the float64 value needed to pass to SetAllSinkVolumes 0..1.0
// Percentages should be passed as integers. 25% -> 25
func ConvertVolume(volPerc int) float64 {
if volPerc == 0 {
return 0.0
}
return float64(volPerc) / 100.0
}
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
func parseSinks(data []byte) ([]Sink, error) {
var sinks []Sink
scanner := bufio.NewScanner(bytes.NewReader(data))
inAudioSection := false
inSinksSection := false
for scanner.Scan() {
line := scanner.Text()
stripped := strings.TrimLeft(line, " │├└─")
// Detect top-level sections: "Audio", "Video", "Settings", etc.
// These are lines with no leading tree characters.
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
inAudioSection = strings.TrimSpace(line) == "Audio"
inSinksSection = false
continue
}
if !inAudioSection {
continue
}
// Detect subsection headers within Audio
if strings.Contains(stripped, "Sinks:") {
inSinksSection = true
continue
}
// Any other subsection header resets sink parsing
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
inSinksSection = false
continue
}
if !inSinksSection {
continue
}
sink, ok := parseSinkLine(stripped)
if ok {
sinks = append(sinks, sink)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning wpctl output: %w", err)
}
return sinks, nil
}
// parseSinkLine parses a single sink entry line like:
//
// "*51. USB Audio [vol: 1.00]"
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
func parseSinkLine(s string) (Sink, bool) {
s = strings.TrimSpace(s)
if s == "" {
return Sink{}, false
}
var sink Sink
// Leading '*' marks the default sink
if strings.HasPrefix(s, "*") {
sink.IsDefault = true
s = strings.TrimPrefix(s, "*")
s = strings.TrimSpace(s)
}
// Expect "<id>. <name> ..."
dotIdx := strings.Index(s, ".")
if dotIdx < 0 {
return Sink{}, false
}
idStr := strings.TrimSpace(s[:dotIdx])
id, err := strconv.Atoi(idStr)
if err != nil {
return Sink{}, false
}
sink.ID = id
rest := strings.TrimSpace(s[dotIdx+1:])
// Extract [vol: X.XX] if present
volStart := strings.Index(rest, "[vol:")
if volStart >= 0 {
sink.Name = strings.TrimSpace(rest[:volStart])
volPart := rest[volStart:]
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
volPart = strings.TrimPrefix(volPart, "[vol:")
volPart = strings.TrimRight(volPart, "]")
volPart = strings.TrimSpace(volPart)
fields := strings.Fields(volPart)
if len(fields) > 0 {
v, err := strconv.ParseFloat(fields[0], 64)
if err == nil {
sink.Volume = v
}
}
} else {
sink.Name = strings.TrimSpace(rest)
}
return sink, true
}
// 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 {
return deviceName
}
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
func SwitchDevice(newName string, cfg *db.AppConfig) error {
mu.Lock()
defer mu.Unlock()
if !inited {
return errors.New("audio mixer not initialized")
}
// Log device switch attempt
// Automatically defaults to "system default"
oldDevice := CurrentDevice() // Which device is to be closed
// SDL expects "" for default device, not "system default"
if newName == "" { // Which device is being switched to?
newName = ""
}
// Capture current state
wasPlaying := mix.PlayingMusic()
savedPath := currentPath
savedLoops := currentLoops
// Stop and release current music before reopening device
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
currentMusic = nil
}
currentPath = ""
currentLoops = 0
// Close and reopen device
if deviceOpen {
mix.CloseAudio()
deviceOpen = false
}
// Attempt to open the newName device
if err := openDeviceLocked(newName); err != nil {
// If error switching to new device, switch back to the old device
// oldDevice is already "" for default (which openDeviceLocked expects)
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
// We should not hit an error when switching back, but if we do...
logger.Error("Failed to switch to device: %s", oldDevice)
}
// Could not switch to the new device, return the error
return err
}
// Restore saved volume after reopening device
if cfg != nil {
SetAllSinkVolumes(cfg.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)
}
}
logger.Info("Audio device switched to %s", newName)
return nil
}
func GetVolume128() int {
mu.Lock()
defer mu.Unlock()
return mix.VolumeMusic(-1)
}
// 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 inited {
mix.Quit() // Add this to clean up the decoders
sdl.QuitSubSystem(sdl.INIT_AUDIO)
inited = false
}
}
@@ -0,0 +1,312 @@
package mixer
import (
"testing"
"time"
"rhemawavetech.com/rhemawave/internal/db"
)
// Regression tests for critical audio bugs (v0.5)
// Regression: Stop() used to return early when AudioEnabled was false,
// leaving music playing after the user toggled audio off mid-playback.
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
mu.Lock()
wasInited := inited
inited = true
deviceOpen = false
currentPath = "/some/track.wav"
currentLoops = -1
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
currentPath = ""
currentLoops = 0
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: false}
Stop(0, cfg)
mu.Lock()
defer mu.Unlock()
if currentPath != "" {
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
}
if currentLoops != 0 {
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
}
}
// Regression: SwitchDevice used to convert oldDevice="" to "system default"
// and pass that string to openDeviceLocked, which SDL does not recognize.
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
err := SwitchDevice("Some Device", cfg)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
}
// Regression: SwitchDevice with nil config must not panic when attempting
// to restore volume after reopening the device.
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
err := SwitchDevice("Some Device", nil)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
}
// Regression: SwitchDevice with empty device name (system default) should
// not be converted to "system default" before passing to openDeviceLocked.
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{SavedVolume: 0.75}
err := SwitchDevice("", cfg)
// Returns error because mixer is not initialized, but should not panic
// and should handle the empty name gracefully.
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
}
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
// (not a deadlock) when the mixer has not been initialized.
// The old code would deadlock because Play() held mu then called SwitchDevice()
// which also tried to acquire mu.
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
// Ensure mixer is in uninitialized state
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: true}
done := make(chan error, 1)
go func() {
done <- Play("nonexistent.wav", false, cfg)
}()
select {
case err := <-done:
if err == nil {
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
}
case <-time.After(5 * time.Second):
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
}
}
func TestParseSinkLine(t *testing.T) {
tests := []struct {
name string
input string
wantOK bool
wantID int
wantName string
wantVol float64
wantDef bool
}{
{
name: "default sink with volume",
input: "*51. USB Audio [vol: 1.00]",
wantOK: true,
wantID: 51,
wantName: "USB Audio",
wantVol: 1.00,
wantDef: true,
},
{
name: "non-default sink with volume",
input: "33. Built-in Audio Analog Stereo [vol: 0.65]",
wantOK: true,
wantID: 33,
wantName: "Built-in Audio Analog Stereo",
wantVol: 0.65,
wantDef: false,
},
{
name: "default sink with muted tag",
input: "*51. USB Audio [vol: 0.50 MUTED]",
wantOK: true,
wantID: 51,
wantName: "USB Audio",
wantVol: 0.50,
wantDef: true,
},
{
name: "sink without volume field",
input: "33. Built-in Audio Analog Stereo",
wantOK: true,
wantID: 33,
wantName: "Built-in Audio Analog Stereo",
wantVol: 0.0,
wantDef: false,
},
{
name: "empty string",
input: "",
wantOK: false,
},
{
name: "no dot separator",
input: "no dot here",
wantOK: false,
},
{
name: "non-numeric ID",
input: "abc. Some Device [vol: 0.80]",
wantOK: false,
},
{
name: "zero volume",
input: "1. Muted Device [vol: 0.00]",
wantOK: true,
wantID: 1,
wantName: "Muted Device",
wantVol: 0.0,
wantDef: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sink, ok := parseSinkLine(tt.input)
if ok != tt.wantOK {
t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
}
if !ok {
return
}
if sink.ID != tt.wantID {
t.Errorf("ID = %d, want %d", sink.ID, tt.wantID)
}
if sink.Name != tt.wantName {
t.Errorf("Name = %q, want %q", sink.Name, tt.wantName)
}
if sink.Volume != tt.wantVol {
t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol)
}
if sink.IsDefault != tt.wantDef {
t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef)
}
})
}
}
func TestParseSinks(t *testing.T) {
tests := []struct {
name string
input string
wantCount int
wantNames []string
}{
{
name: "two sinks",
input: `Audio
Sinks:
*51. USB Audio [vol: 1.00]
33. Built-in Audio Analog Stereo [vol: 0.65]
Sources:
52. Microphone [vol: 0.80]`,
wantCount: 2,
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
},
{
name: "no sinks section",
input: `Audio
Sources:
52. Microphone [vol: 0.80]`,
wantCount: 0,
},
{
name: "sinks with tree characters",
input: `Audio
├─Sinks:
│ ├─*51. USB Audio [vol: 1.00]
│ └─33. Built-in Audio Analog Stereo [vol: 0.65]
└─Sources:
└─52. Microphone [vol: 0.80]`,
wantCount: 2,
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
},
{
name: "empty input",
input: ``,
wantCount: 0,
},
{
name: "video section ignored",
input: `Video
Sinks:
100. HDMI Output [vol: 1.00]
Audio
Sinks:
51. USB Audio [vol: 1.00]`,
wantCount: 1,
wantNames: []string{"USB Audio"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sinks, err := parseSinks([]byte(tt.input))
if err != nil {
t.Fatalf("parseSinks returned error: %v", err)
}
if len(sinks) != tt.wantCount {
t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount)
}
for i, name := range tt.wantNames {
if sinks[i].Name != name {
t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name)
}
}
})
}
}
+38
View File
@@ -0,0 +1,38 @@
package mixer_test
import (
"math"
"testing"
"rhemawavetech.com/rhemawave/internal/mixer"
)
func TestConvertVolume(t *testing.T) {
tests := []struct {
name string
input int
expected float64
}{
{"0 percent", 0, 0.0},
{"25 percent", 25, 0.25},
{"33 percent", 33, 0.33},
{"50 percent", 50, 0.50},
{"90 percent", 90, 0.90},
{"100 percent", 100, 1.0},
{"1 percent", 1, 0.01},
{"99 percent", 99, 0.99},
{"negative 10", -10, -0.10},
{"over 100", 150, 1.50},
}
const epsilon = 1e-9
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mixer.ConvertVolume(tt.input)
if math.Abs(got-tt.expected) > epsilon {
t.Errorf("ConvertVolume(%d) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package netreq
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// Makes a GET request to the specified URL and returns the JSON response
// as a byte slice. If the request fails, it returns an error.
func GetJSON[T any](url string) ([]T, error) {
fmt.Println("Fetching:", url)
res, err := http.Get(url)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode > 299 {
return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body))
}
// 1. Trim whitespace to find the first actual JSON character
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, nil
}
var result []T
// 2. Determine if we are looking at an Array [ ] or an Object { }
if trimmed[0] == '[' {
// It's already an array, unmarshal directly
err = json.Unmarshal(body, &result)
} else {
// It's a single object, unmarshal into a single instance and wrap it
var single T
err = json.Unmarshal(body, &single)
result = []T{single}
}
if err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return result, nil
}
// Download downloads a specified object at a given url and places it in
// the provided dest directory.
func Download(name string, path string, url string) error {
fullFilePath := filepath.Join(path, name)
if err := os.MkdirAll(path, os.ModeDir); err != nil {
return err
}
file, err := os.Create(fullFilePath)
if err != nil {
return err
}
defer file.Close()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return err
}
return nil
}
+577
View File
@@ -0,0 +1,577 @@
package network
import (
"context"
"fmt"
"net"
"net/http"
"time"
gonm "github.com/Wifx/gonetworkmanager/v2"
"github.com/google/uuid"
"rhemawavetech.com/rhemawave/internal/logger"
)
// ─── Public types ─────────────────────────────────────────────────────────────
type SecurityType string
const (
SecurityOpen SecurityType = "Open"
SecurityWEP SecurityType = "WEP"
SecurityWPA SecurityType = "WPA"
SecurityWPA2 SecurityType = "WPA2"
SecurityWPA3 SecurityType = "WPA3"
)
type Network struct {
SSID string
SignalStrength uint8
Security SecurityType
Saved bool
Connected bool
}
// ─── Package init ─────────────────────────────────────────────────────────────
var nm gonm.NetworkManager
func init() {
var err error
nm, err = gonm.NewNetworkManager()
if err != nil {
logger.Warning("network: NetworkManager unavailable, network features disabled: %v", err)
}
}
// ─── ScanForNetworks ──────────────────────────────────────────────────────────
func ScanForNetworks(ctx context.Context) (<-chan []Network, error) {
dev, err := wifiDevice()
if err != nil {
return nil, err
}
ch := make(chan []Network, 1)
go func() {
defer close(ch)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
scan := func() {
if err := dev.RequestScan(); err != nil {
logger.Warning("network: scan request failed: %v", err)
return
}
time.Sleep(2 * time.Second)
nets, err := buildNetworkList(dev)
if err != nil {
logger.Warning("network: building network list failed: %v", err)
return
}
select {
case ch <- nets:
case <-ctx.Done():
}
}
scan()
for {
select {
case <-ticker.C:
scan()
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
// ─── ConnectToNetwork ─────────────────────────────────────────────────────────
func ConnectToNetwork(ssid, password string) error {
dev, err := wifiDevice()
if err != nil {
return err
}
ifaceName, err := dev.GetPropertyInterface()
if err != nil {
return fmt.Errorf("network: cannot get interface name: %w", err)
}
ap, err := findAccessPoint(dev, ssid)
if err != nil {
return fmt.Errorf("network: access point %q not found: %w", ssid, err)
}
// Delete any existing profiles for this SSID before creating a new one.
// Stale/duplicate profiles cause NM to fail silently.
if err := deleteExistingConnections(ssid); err != nil {
logger.Warning("network: could not clean up old profiles for %q: %v", ssid, err)
}
security := detectSecurity(ap)
settings, err := buildConnectionSettings(ssid, password, security, ifaceName)
if err != nil {
return err
}
logger.Info("network: connecting to %q (%s)", ssid, security)
_, err = nm.AddAndActivateConnection(settings, dev)
if err != nil {
return fmt.Errorf("network: failed to connect to %q: %w", ssid, err)
}
return waitForConnection(dev, ssid, 20*time.Second)
}
// deleteExistingConnections removes all stored NM profiles for the given SSID.
func deleteExistingConnections(ssid string) error {
s, err := gonm.NewSettings()
if err != nil {
return err
}
connections, err := s.ListConnections()
if err != nil {
return err
}
for _, conn := range connections {
cfg, err := conn.GetSettings()
if err != nil {
continue
}
wireless, ok := cfg["802-11-wireless"]
if !ok {
continue
}
rawSSID, ok := wireless["ssid"]
if !ok {
continue
}
if string(rawSSID.([]byte)) != ssid {
continue
}
logger.Debug("network: removing existing profile for %q", ssid)
if err := conn.Delete(); err != nil {
logger.Warning("network: failed to delete profile for %q: %v", ssid, err)
}
}
return nil
}
// ─── ForgetNetwork ────────────────────────────────────────────────────────────
func ForgetNetwork(ssid string) error {
s, err := gonm.NewSettings()
if err != nil {
return fmt.Errorf("network: cannot access NM settings: %w", err)
}
connections, err := s.ListConnections()
if err != nil {
return fmt.Errorf("network: cannot list connections: %w", err)
}
found := false
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
wireless, ok := settings["802-11-wireless"]
if !ok {
continue
}
rawSSID, ok := wireless["ssid"]
if !ok {
continue
}
if string(rawSSID.([]byte)) != ssid {
continue
}
found = true
logger.Info("network: forgetting %q", ssid)
if err := conn.Delete(); err != nil {
return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err)
}
}
if !found {
return fmt.Errorf("network: no saved connection found for %q", ssid)
}
return nil
}
// ─── CurrentNetwork ───────────────────────────────────────────────────────────
func CurrentNetwork() (string, error) {
dev, err := wifiDevice()
if err != nil {
return "", err
}
ap, err := dev.GetPropertyActiveAccessPoint()
if err != nil || ap == nil {
return "", nil
}
ssid, err := ap.GetPropertySSID()
if err != nil {
return "", err
}
return ssid, nil
}
// ─── ListSavedNetworks ────────────────────────────────────────────────────────
func ListSavedNetworks() ([]string, error) {
s, err := gonm.NewSettings()
if err != nil {
return nil, fmt.Errorf("network: cannot access NM settings: %w", err)
}
connections, err := s.ListConnections()
if err != nil {
return nil, fmt.Errorf("network: cannot list connections: %w", err)
}
var ssids []string
for _, conn := range connections {
settings, err := conn.GetSettings()
if err != nil {
continue
}
wireless, ok := settings["802-11-wireless"]
if !ok {
continue
}
rawSSID, ok := wireless["ssid"]
if !ok {
continue
}
ssids = append(ssids, string(rawSSID.([]byte)))
}
return ssids, nil
}
// ─── IsConnected ──────────────────────────────────────────────────────────────
func IsConnected() (bool, error) {
if nm == nil {
return false, fmt.Errorf("network: NetworkManager not available")
}
state, err := nm.GetPropertyState()
if err != nil {
return false, fmt.Errorf("network: cannot get NM state: %w", err)
}
return state >= gonm.NmStateConnectedLocal, nil
}
// ─── HasInternet ──────────────────────────────────────────────────────────────
func HasInternet() bool {
targets := []string{
"1.1.1.1:53",
"1.0.0.1:53",
"8.8.8.8:53",
"9.9.9.9:53",
"8.8.4.4:53",
"google.com:443",
"cloudflare.com:443",
}
results := make(chan bool, len(targets))
for _, target := range targets {
go func(addr string) {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
conn.Close()
results <- true
} else {
results <- false
}
}(target)
}
for range targets {
if <-results {
return true
}
}
return false
}
// ─── WatchState ───────────────────────────────────────────────────────────────
func WatchState(ctx context.Context) (<-chan bool, error) {
if nm == nil {
return nil, fmt.Errorf("network: NetworkManager not available")
}
ch := make(chan bool, 1)
go func() {
defer close(ch)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
var last *bool
for {
select {
case <-ticker.C:
connected, err := IsConnected()
if err != nil {
logger.Warning("network: WatchState poll failed: %v", err)
continue
}
if last == nil || *last != connected {
last = &connected
select {
case ch <- connected:
case <-ctx.Done():
return
}
}
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
func wifiDevice() (gonm.DeviceWireless, error) {
if nm == nil {
return nil, fmt.Errorf("network: NetworkManager not available")
}
devices, err := nm.GetPropertyAllDevices()
if err != nil {
return nil, fmt.Errorf("network: cannot list devices: %w", err)
}
for _, dev := range devices {
devType, err := dev.GetPropertyDeviceType()
if err != nil {
continue
}
if devType == gonm.NmDeviceTypeWifi {
return gonm.NewDeviceWireless(dev.GetPath())
}
}
return nil, fmt.Errorf("network: no WiFi device found")
}
func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) {
aps, err := dev.GetPropertyAccessPoints()
if err != nil {
return nil, err
}
for _, ap := range aps {
apSSID, err := ap.GetPropertySSID()
if err != nil {
continue
}
if apSSID == ssid {
return ap, nil
}
}
return nil, fmt.Errorf("not found")
}
func detectSecurity(ap gonm.AccessPoint) SecurityType {
flags, _ := ap.GetPropertyFlags()
wpaFlags, _ := ap.GetPropertyWPAFlags()
rsnFlags, _ := ap.GetPropertyRSNFlags()
// WPA3 uses SAE (0x400)
if rsnFlags&0x400 != 0 {
return SecurityWPA3
}
// WPA2 uses PSK (0x100)
if rsnFlags&0x100 != 0 {
return SecurityWPA2
}
// WPA uses WPA flags (not RSN)
if wpaFlags != 0 {
return SecurityWPA
}
// WEP uses privacy flag
if flags&0x1 != 0 {
return SecurityWEP
}
return SecurityOpen
}
func buildConnectionSettings(ssid, password string, security SecurityType, interfaceName string) (map[string]map[string]interface{}, error) {
settings := map[string]map[string]interface{}{
"connection": {
"id": ssid,
"type": "802-11-wireless",
"uuid": uuid.NewString(),
"interface-name": interfaceName,
"autoconnect": true,
"permissions": []string{},
},
"802-11-wireless": {
"ssid": []byte(ssid),
"mode": "infrastructure",
},
"ipv4": {"method": "auto"},
"ipv6": {"method": "auto"},
}
switch security {
case SecurityOpen:
// nothing
case SecurityWEP:
if password == "" {
return nil, fmt.Errorf("network: WEP network requires a password")
}
settings["802-11-wireless-security"] = map[string]interface{}{
"key-mgmt": "none",
"wep-key0": password,
"wep-key-type": uint32(1),
}
case SecurityWPA, SecurityWPA2:
if password == "" {
return nil, fmt.Errorf("network: WPA/WPA2 network requires a password")
}
settings["802-11-wireless-security"] = map[string]interface{}{
"key-mgmt": "wpa-psk",
"auth-alg": "open",
"psk": password,
"psk-flags": uint32(0),
}
case SecurityWPA3:
if password == "" {
return nil, fmt.Errorf("network: WPA3 network requires a password")
}
settings["802-11-wireless-security"] = map[string]interface{}{
"key-mgmt": "sae",
"psk": password,
}
}
return settings, nil
}
func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) {
aps, err := dev.GetPropertyAccessPoints()
if err != nil {
return nil, err
}
activeAP, _ := dev.GetPropertyActiveAccessPoint()
var activeSSID string
if activeAP != nil {
activeSSID, _ = activeAP.GetPropertySSID()
}
saved, _ := ListSavedNetworks()
savedSet := make(map[string]bool, len(saved))
for _, s := range saved {
savedSet[s] = true
}
var networks []Network
seen := make(map[string]bool)
for _, ap := range aps {
ssid, err := ap.GetPropertySSID()
if err != nil || ssid == "" {
continue
}
if seen[ssid] {
continue
}
seen[ssid] = true
strength, _ := ap.GetPropertyStrength()
networks = append(networks, Network{
SSID: ssid,
SignalStrength: strength,
Security: detectSecurity(ap),
Saved: savedSet[ssid],
Connected: ssid == activeSSID,
})
}
return networks, nil
}
// waitForConnection waits until the wifi device reaches the Activated state,
// meaning DHCP has completed and the connection is fully up.
func waitForConnection(dev gonm.DeviceWireless, ssid string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
state, err := dev.GetPropertyState()
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
logger.Debug("network: device state %d, waiting...", state)
switch state {
case gonm.NmDeviceStateActivated:
current, err := CurrentNetwork()
if err == nil && current == ssid {
logger.Info("network: fully connected to %q (DHCP complete)", ssid)
return nil
}
case gonm.NmDeviceStateFailed:
return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid)
case gonm.NmDeviceStateDisconnected:
return fmt.Errorf("network: connection to %q was rejected by the network", ssid)
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("network: timed out waiting for connection to %q", ssid)
}
var internetCheckURL = "https://clients3.google.com/generate_204"
func httpCheck() bool {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(internetCheckURL)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 204
}
var _ = httpCheck
+626
View File
@@ -0,0 +1,626 @@
package page
import (
"fmt"
"math"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
const focusToggle = -1
// AudioFileList is kept as the canonical seed data for the audio_files table.
// Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database.
var AudioFileList = []db.AudioFile{
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
}
const triangleSize = float32(16)
type AudioDevice struct {
Label string
ID uint32
index int
}
type AudioOptions struct {
// Page Attributes
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
CfgPtr *db.AppConfig
// Page Textures
title StringItem
prompt StringItem
// Layout
verticalMargin int32
ItemScale float32
// Audio toggle
AudioStatusToggle StringItem
// Audio Devices
activeDeviceIndex int
NumAudioDevices int
AudioDevices []AudioDevice
AudioDeviceTextures []StringItem
// Volume row
VolumeLabel StringItem
volumePercent StringItem
volumeBarX int32
volumeBarY int32
volumeBarW int32
volumeBarH int32
// Triangle cursor
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
focusState int
}
// ── Init ──────────────────────────────────────────────────────────────────────
func (p *AudioOptions) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.focusState = focusToggle
// Font
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
// Shaders
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
}
p.solidShader, ok = sm.Get("solid_color")
if !ok {
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
}
if err = p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
baseFontSize := config.StandardFontSize
titleScale := float32(64) / float32(baseFontSize)
p.ItemScale = float32(32) / float32(baseFontSize)
// Title
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
p.title.Scale(titleScale)
// Prompt
p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
p.font, colors.White,
)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
p.prompt.Scale(p.ItemScale)
// Volume row
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating volume label: %w", err)
}
p.VolumeLabel.Scale(p.ItemScale)
if err = p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
}
// Audio toggle
if err = p.CreateAudioStatusItem(); err != nil {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
// Audio output devices
if err = p.GetAudioDevices(); err != nil {
logger.Error("failed to enumerate audio devices: %v", err)
}
// Set active device index based on mixer's current device
currentName := mixer.CurrentDevice()
config, err = db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
p.activeDeviceIndex = -1 // default to not found
for i, dev := range p.AudioDevices {
if dev.Label == currentName {
p.activeDeviceIndex = i
break
}
}
// If not found and current device is default (""), check saved config
if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" {
// Default device is selected - first device in SDL list is typically the default
if len(p.AudioDevices) > 0 {
p.activeDeviceIndex = 0
}
}
// Make sure the audio_files table is populated.
if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err)
}
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
-0.5, -0.5,
}
gl.GenVertexArrays(1, &p.triVAO)
gl.GenBuffers(1, &p.triVBO)
gl.BindVertexArray(p.triVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.layout()
return nil
}
// seedAudioFiles ensures the audio_files table has its default rows.
func (p *AudioOptions) seedAudioFiles() error {
var existing []db.AudioFile
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&AudioFileList).Error
}
return nil
}
// ── Audio status toggle ───────────────────────────────────────────────────────
func (p *AudioOptions) CreateAudioStatusItem() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
on := config.AudioEnabled
statusString := "Turn Audio On"
color := colors.Green
if on {
statusString = "Turn Audio Off"
color = colors.Red
}
item, err := NewStringItem(statusString, p.font, color)
if err != nil {
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
}
item.Scale(p.ItemScale)
p.AudioStatusToggle = item
return nil
}
// ── Audio Output Devices ──────────────────────────────────────────────────────
func (p *AudioOptions) GetAudioDevices() error {
numAudioDevices := sdl.GetNumAudioDevices(false)
if numAudioDevices == 0 {
return nil
}
// Pre-allocate with zero length so append doesn't produce leading zero-value
// elements (the old code used make([]AudioDevice, n) then appended, which
// doubled the slice length with n zero-value entries at the front).
audioDevices := make([]AudioDevice, 0, numAudioDevices)
for i := range numAudioDevices {
label := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
}
p.AudioDevices = audioDevices
for i := range audioDevices {
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
if err != nil {
return err
}
item.Scale(p.ItemScale)
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
}
p.NumAudioDevices = numAudioDevices
return nil
}
// SelectAudioDevice is called when the user confirms a device from the list.
// The device-switching implementation goes here; for now we just log the choice.
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
return fmt.Errorf("invalid device index: %d", deviceIndex)
}
device := p.AudioDevices[deviceIndex]
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
return fmt.Errorf("failed to get config for device switch: %w", err)
}
if err := mixer.SwitchDevice(device.Label, config); err != nil {
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
}
// Persist the selected device to the database
config.SelectedAudioDevice = device.Label
if err := p.Context.DbPtr.Save(config).Error; err != nil {
logger.Error("failed to persist device selection: %v", err)
}
p.activeDeviceIndex = deviceIndex
return nil
}
// ── Volume ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) CreateVolumePercentItem() error {
if p.volumePercent.ID != 0 {
gl.DeleteTextures(1, &p.volumePercent.ID)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
pct := int(config.SavedVolume * 100)
item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White)
if err != nil {
return fmt.Errorf("failed to create volume percent item: %v", err)
}
item.Scale(p.ItemScale)
p.volumePercent = item
return nil
}
func (p *AudioOptions) UpdateVolume(level int) {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
newVol := mixer.ConvertVolume(level) + config.SavedVolume
if newVol < 0.0 || newVol > 1.0 {
return
}
if newVol > .99 {
newVol = 1.0
}
config.SavedVolume = newVol
p.Context.DbPtr.Save(&config)
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
logger.Error("Error setting allSinkVolumes: %v", err)
}
if err := p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to refresh volume percent item: %v", err)
}
gap := int32(12)
p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
}
// ── Layout ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) layout() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Title
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Prompt
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
// Volume row
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
barW := int32(float32(p.ScreenWidth) * 0.30)
barX := p.ScreenCenterX - barW/2
barY := p.prompt.Y - p.verticalMargin*2 - barH
p.volumeBarX = barX
p.volumeBarY = barY
p.volumeBarW = barW
p.volumeBarH = barH
p.VolumeLabel.X = barX - p.VolumeLabel.W - 20
p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2
gap := int32(12)
p.volumePercent.X = barX + barW + gap
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
// Audio toggle — centred below the volume row
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Device list — each item centred and stacked below the toggle
nextY := p.AudioStatusToggle.Y - p.verticalMargin
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
item.X = p.ScreenCenterX - item.W/2
item.Y = nextY - item.H
nextY = item.Y - p.verticalMargin
}
// Reposition the triangle cursor for whatever row currently has focus
p.updateCursorPosition()
}
// updateCursorPosition moves triX / triCenterY to sit just left of the
// focused row. Call this any time focusState changes or layout() reruns.
func (p *AudioOptions) updateCursorPosition() {
cursorGap := int32(12)
var item *StringItem
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
item = &p.AudioDeviceTextures[p.focusState]
default:
return
}
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(item.Y) + float32(item.H)/2
}
// ── Toggle ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) ToggleAudio() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
logger.Error("panic! Cannot query database for configuration")
panic(err)
}
config.AudioEnabled = !config.AudioEnabled
p.Context.DbPtr.Save(config)
p.CreateAudioStatusItem()
p.layout()
}
// ── Events ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
e, ok := (*event).(*sdl.KeyboardEvent)
if !ok || e.Type != sdl.KEYDOWN {
return nil
}
switch e.Keysym.Sym {
case sdl.K_RETURN:
if p.focusState == focusToggle {
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
if err := p.SelectAudioDevice(p.focusState); err != nil {
logger.Error("SelectAudioDevice: %v", err)
}
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if len(p.AudioDeviceTextures) > 0 {
p.focusState = 0
p.updateCursorPosition()
}
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
p.focusState++
p.updateCursorPosition()
}
case sdl.K_UP:
if p.focusState == 0 {
p.focusState = focusToggle
p.updateCursorPosition()
} else if p.focusState > 0 {
p.focusState--
p.updateCursorPosition()
}
case sdl.K_RIGHT:
p.UpdateVolume(10)
case sdl.K_LEFT:
p.UpdateVolume(-10)
case sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
}
return nil
}
// ── Update ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
p.spinElapsed -= 1.0
}
return nil
}
// ── Render helpers ────────────────────────────────────────────────────────────
func (p *AudioOptions) renderVolumeBar() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
vol := config.SavedVolume
fillW := float32(p.volumeBarW) * float32(vol)
if fillW > 0 {
if err := p.RenderSolidColorQuad(
p.solidShader,
mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)},
mgl32.Vec2{fillW, float32(p.volumeBarH)},
colors.White,
); err != nil {
return fmt.Errorf("rendering volume fill: %w", err)
}
}
thickness := float32(2)
bx := float32(p.volumeBarX)
by := float32(p.volumeBarY)
bw := float32(p.volumeBarW)
bh := float32(p.volumeBarH)
outlineRects := [4][2]mgl32.Vec2{
{{bx, by}, {bw, thickness}},
{{bx, by + bh - thickness}, {bw, thickness}},
{{bx, by}, {thickness, bh}},
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
}
for _, r := range outlineRects {
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
return fmt.Errorf("rendering volume outline: %w", err)
}
}
return nil
}
// renderCursor draws the spinning triangle cursor next to the focused row.
func (p *AudioOptions) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
mvp := p.Projection.Mul4(model)
gl.UseProgram(p.solidShader.ProgramID)
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
gl.Uniform4f(
p.solidShader.LocColor,
float32(colors.White.R)/255.0,
float32(colors.White.G)/255.0,
float32(colors.White.B)/255.0,
float32(colors.White.A)/255.0,
)
gl.BindVertexArray(p.triVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 3)
gl.BindVertexArray(0)
gl.UseProgram(0)
return nil
}
// ── Render ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) Render() error {
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
return fmt.Errorf("render title: %w", err)
}
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
return fmt.Errorf("render prompt: %w", err)
}
// Volume row
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
return fmt.Errorf("render volume label: %w", err)
}
if err := p.renderVolumeBar(); err != nil {
return fmt.Errorf("render volume bar: %w", err)
}
if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil {
return fmt.Errorf("render volume percent: %w", err)
}
// Toggle
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
return fmt.Errorf("render audio toggle: %w", err)
}
// Device list
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
color := colors.White
if i == p.activeDeviceIndex {
color = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
return fmt.Errorf("render audio device %d: %w", i, err)
}
}
// Spinning triangle cursor — always on top
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
}
return nil
}
// ── Destroy ───────────────────────────────────────────────────────────────────
func (p *AudioOptions) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
for i := range p.AudioDeviceTextures {
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}
@@ -0,0 +1,28 @@
package page
import (
"github.com/veandco/go-sdl2/sdl"
)
type DownloadingAssets struct {
}
func (p *DownloadingAssets) Init(ctx *PageCtx) error {
return nil
}
func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *DownloadingAssets) Update(dt float32) error {
return nil
}
func (p *DownloadingAssets) Render() error {
return nil
}
func (p *DownloadingAssets) Destroy() error {
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package page
import (
"github.com/veandco/go-sdl2/sdl"
)
type MessagePage struct {
Msg string
}
func (p *MessagePage) Init(ctx *PageCtx) error {
return nil
}
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *MessagePage) Update(dt float32) error {
return nil
}
func (p *MessagePage) Render() error {
return nil
}
func (p *MessagePage) Destroy() error {
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package page
import (
"fmt"
"regexp"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// emailRegex validates email format
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type EmailAddressPage struct {
Base
title string
titleTextureID uint32
titleTextureWidth int32
titleTextureHeight int32
currentEmailDisplayTextureID uint32
currentEmailDisplayTextureWidth int32
currentEmailDisplayTextureHeight int32
inputText string
inputTextTextureID uint32
inputTextTextureWidth int32
inputTextTextureHeight int32
// Error message handling
errorMessage string
errorTextureID uint32
errorTextureWidth int32
errorTextureHeight int32
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
isFirstTimeSetup bool
// Text input deduplication
textInputSupported bool
}
// isValidEmail checks if the given string is a valid email format
func isValidEmail(email string) bool {
return emailRegex.MatchString(email)
}
// Init performs basic page initialization and creation of static assets.
func (p *EmailAddressPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
p.title = "Your Email Address"
applicationFont := "SpaceMonoNerdFont-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
if !ok {
return fmt.Errorf("unable to fetch text shader")
}
// Create title texture
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
if err != nil {
return fmt.Errorf("failed to create title texture: %w", err)
}
if titleTextureID == 0 {
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
}
p.titleTextureID = titleTextureID
p.titleTextureWidth = titleWidth
p.titleTextureHeight = titleHeight
// Load existing email address from database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
emailAddress := config.EmailAddress
if emailAddress != "" {
p.inputText = emailAddress
p.isFirstTimeSetup = false
// Create current email display texture
currentEmailText := fmt.Sprintf("Current Email: %s", emailAddress)
currentEmailTextureID, currentEmailWidth, currentEmailHeight, err := fm.CreateStringTexture(applicationFont, currentEmailText, textShader)
if err != nil {
return fmt.Errorf("failed to create current email display texture: %w", err)
}
if currentEmailTextureID == 0 {
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current email display")
}
p.currentEmailDisplayTextureID = currentEmailTextureID
p.currentEmailDisplayTextureWidth = currentEmailWidth
p.currentEmailDisplayTextureHeight = currentEmailHeight
} else {
p.inputText = ""
p.isFirstTimeSetup = true
}
sdl.StartTextInput()
return nil
}
// HandleEvent processes SDL events and handles them appropriately for the EmailAddressPage.
func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
actualEvent := *event
if !sdl.IsTextInputActive() {
sdl.StartTextInput()
}
switch e := actualEvent.(type) {
case *sdl.TextInputEvent:
if !p.textInputSupported {
// First TextInputEvent: the keyboard fallback already
// handled this character, so just record that text input
// is supported and skip the duplicate append.
p.textInputSupported = true
} else {
p.inputText += e.GetText()
// Clear error when user starts typing
p.errorMessage = ""
}
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym {
case sdl.K_ESCAPE:
// Block escape during first-time setup
if p.isFirstTimeSetup {
return nil
}
// Allow escape back to settings if coming from settings
sdl.StopTextInput()
p.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN:
// Validate email - check empty first, then format
if p.inputText == "" {
p.errorMessage = "Email address cannot be empty"
return nil
}
if !isValidEmail(p.inputText) {
p.errorMessage = "Please enter a valid email address"
return nil
}
sdl.StopTextInput()
// Save email address to database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
config.EmailAddress = p.inputText
p.Context.DbPtr.Save(config)
if p.isFirstTimeSetup {
// First-time setup: navigate to License Key page
p.Context.NavigateTo(LICENSE_KEY)
} else {
p.Context.NavigateTo(SETTINGS)
}
case sdl.K_BACKSPACE:
if len(p.inputText) > 0 {
p.inputText = p.inputText[:len(p.inputText)-1]
// Clear error when user edits input
p.errorMessage = ""
}
default:
// Keyboard fallback: if TextInputEvent is not supported,
// convert printable keycodes to characters directly.
// This handles kiosk systems without an input method framework.
if !p.textInputSupported {
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
p.inputText += string(ch)
p.errorMessage = ""
}
}
}
}
}
return nil
}
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
func (p *EmailAddressPage) Update(dt float32) error {
// Get application font from database
applicationFont := "SpaceMonoNerdFont-Regular"
fm := fontManager.Get()
sm := shaderManager.Get()
shader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("unable to fetch text shader")
}
// Update input text texture
if len(p.inputText) > 0 {
// Create the new inputText texture
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
applicationFont,
p.inputText,
shader,
)
if err != nil {
return fmt.Errorf("unable to create new input text texture: %w", err)
}
// Delete old texture if it exists
if p.inputTextTextureID != 0 {
gl.DeleteTextures(1, &p.inputTextTextureID)
}
p.inputTextTextureID = inputTextTextureID
p.inputTextTextureWidth = inputTextTextureWidth
p.inputTextTextureHeight = inputTextTextureHeight
}
// Update error message texture
if p.errorMessage != "" {
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
applicationFont,
p.errorMessage,
shader,
)
if err != nil {
return fmt.Errorf("unable to create error text texture: %w", err)
}
// Delete old error texture if it exists
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
}
p.errorTextureID = errorTextureID
p.errorTextureWidth = errorWidth
p.errorTextureHeight = errorHeight
} else {
// Clear error texture if no error message
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
p.errorTextureID = 0
}
}
return nil
}
// Render performs page drawing logic.
func (p *EmailAddressPage) Render() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Get base font size from database
baseFontSize := config.StandardFontSize
sm := shaderManager.Get()
// 1. Calculate Scale
titleFontSize := int32(64)
titleScale := float32(titleFontSize) / float32(baseFontSize)
currentEmailFontSize := int32(28)
currentEmailScale := float32(currentEmailFontSize) / float32(baseFontSize)
padding := int32(4)
verticalMargin := int32(20)
// 2. Define dimensions
targetFontSize := int32(36)
scale := float32(targetFontSize) / float32(baseFontSize)
border := int32(3)
boxH := targetFontSize + (padding * 2)
boxW := int32(600) + (padding * 2)
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
borderBoxH := boxH + (border * 2)
borderBoxW := boxW + (border * 2)
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
titleH := float32(p.titleTextureHeight) * titleScale
titleW := float32(p.titleTextureWidth) * titleScale
titleDimensions := mgl32.Vec2{titleW, titleH}
inputTextH := float32(p.inputTextTextureHeight) * scale
inputTextW := float32(p.inputTextTextureWidth) * scale
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
var currentEmailDisplayH, currentEmailDisplayW float32
var currentEmailDisplayDimensions mgl32.Vec2
if p.currentEmailDisplayTextureID != 0 {
currentEmailDisplayH = float32(p.currentEmailDisplayTextureHeight) * currentEmailScale
currentEmailDisplayW = float32(p.currentEmailDisplayTextureWidth) * currentEmailScale
currentEmailDisplayDimensions = mgl32.Vec2{currentEmailDisplayW, currentEmailDisplayH}
}
// Error text dimensions
errorFontSize := int32(20)
errorScale := float32(errorFontSize) / float32(baseFontSize)
var errorTextH, errorTextW float32
var errorTextDimensions mgl32.Vec2
if p.errorTextureID != 0 {
errorTextH = float32(p.errorTextureHeight) * errorScale
errorTextW = float32(p.errorTextureWidth) * errorScale
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
}
// 3. Define positions
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
boxY := p.Base.ScreenCenterY - (boxH / 2)
boxX := p.Base.ScreenCenterX - (boxW / 2)
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
titleY := borderBoxY + borderBoxH + verticalMargin
titleX := p.Base.ScreenCenterX - int32(titleW/2)
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
inputTextY := boxY + padding
inputTextX := boxX + padding
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
var currentEmailDisplayPosition mgl32.Vec2
if p.currentEmailDisplayTextureID != 0 {
currentEmailDisplayY := borderBoxY - int32(currentEmailDisplayH) - verticalMargin
currentEmailDisplayX := p.Base.ScreenCenterX - int32(currentEmailDisplayW/2)
currentEmailDisplayPosition = mgl32.Vec2{float32(currentEmailDisplayX), float32(currentEmailDisplayY)}
}
// Error text position (below current email display to avoid overlap)
var errorTextPosition mgl32.Vec2
if p.errorTextureID != 0 {
var errorTextY int32
if p.currentEmailDisplayTextureID != 0 {
// Position below the current email display
errorTextY = borderBoxY - int32(currentEmailDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
} else {
// No current email display, position below the input box
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
}
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
}
// 4. Define Colors
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
grey := sdl.Color{R: 120, G: 120, B: 120, A: 255}
darkGrey := sdl.Color{R: 30, G: 30, B: 30, A: 255}
// 5. Get Shaders
textShader, _ := sm.Get("text")
solidShader, _ := sm.Get("solid_color")
// 6. Draw everything
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, grey)
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, darkGrey)
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, white)
// Draw current email display if it exists
if p.currentEmailDisplayTextureID != 0 {
p.Base.RenderTexture(textShader, p.currentEmailDisplayTextureID, currentEmailDisplayPosition, currentEmailDisplayDimensions, white)
}
if len(p.inputText) > 0 {
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, white)
}
// Draw error message if it exists
if p.errorTextureID != 0 {
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
}
return nil
}
// Destroy cleans up page-specific resources.
func (p *EmailAddressPage) Destroy() error {
gl.DeleteTextures(1, &p.titleTextureID)
// Delete current email display texture if it exists
if p.currentEmailDisplayTextureID != 0 {
gl.DeleteTextures(1, &p.currentEmailDisplayTextureID)
p.currentEmailDisplayTextureID = 0
}
// Delete input text texture if it exists
if p.inputTextTextureID != 0 {
gl.DeleteTextures(1, &p.inputTextTextureID)
p.inputTextTextureID = 0
}
// Delete error texture if it exists
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
p.errorTextureID = 0
}
sdl.StopTextInput()
return p.Base.Destroy()
}
+419
View File
@@ -0,0 +1,419 @@
package page
import (
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
var GitVersion = "07112026-1710"
type Home struct {
Base
fm *fontManager.FontManager
font *fontManager.FontEntry
title StringItem
version StringItem
prompt StringItem
note StringItem
message StringItem
downloadProgressChan chan download.DownloadStatus
licenseInfoHeading StringItem
emailDisplay StringItem
licenseKeyDisplay StringItem
hasLicenseInfo bool
baseFontSize int
isDownloadComplete bool
downloadFailed bool
downloadDone bool
}
// Init performs basic page initialization and creation of static assets.
func (p *Home) Init(ctx *PageCtx) error {
title := "Radiant Wave"
version := GitVersion
prompt := "Press F2 to Start, or F3 for Settings"
note := "You can always press F1 to return here"
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
// Get font
p.fm = fontManager.Get()
font, ok := p.fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("unable to fetch font")
}
p.font = font
// Create title texture
var err error
p.title, err = NewStringItem(title, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create title string texture: %w", err)
}
// Create version texture
versionString := "Version: " + version
p.version, err = NewStringItem(versionString, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create version string texture: %w", err)
}
// Create prompt texture
p.prompt, err = NewStringItem(prompt, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create prompt string texture: %w", err)
}
// Create note texture
p.note, err = NewStringItem(note, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create note string texture: %w", err)
}
// Create download channel and message
p.downloadProgressChan = make(chan download.DownloadStatus)
// Initialize message
p.message, err = NewStringItem("Status: Initializing...", font, colors.White)
if err != nil {
return fmt.Errorf("unable to create initial message texture: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Create license info textures if data exists
if config.EmailAddress != "" || config.LicenseKey != "" {
p.hasLicenseInfo = true
// Create heading
p.licenseInfoHeading, err = NewStringItem("License Info", font, colors.White)
if err != nil {
return fmt.Errorf("unable to create license info heading texture: %w", err)
}
// Create email display if exists
if config.EmailAddress != "" {
emailText := fmt.Sprintf("Email: %s", config.EmailAddress)
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create email display texture: %w", err)
}
}
// Create license key display if exists
if config.LicenseKey != "" {
licenseKeyText := fmt.Sprintf("License Key: %s", config.LicenseKey)
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create license key display texture: %w", err)
}
}
}
// Get base font size from database
baseFontSize := config.StandardFontSize
p.baseFontSize = baseFontSize
// Scale the note text
fontSize := int32(32)
scale := float32(fontSize) / float32(baseFontSize)
p.note.W = int32(float32(p.note.W) * scale * 0.75)
p.note.H = int32(float32(p.note.H) * scale * 0.75)
// Scale license info texts
if p.hasLicenseInfo {
licenseInfoScale := float32(28) / float32(baseFontSize)
p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale)
p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale)
if p.emailDisplay.ID != 0 {
p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85)
p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85)
}
if p.licenseKeyDisplay.ID != 0 {
p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85)
p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85)
}
}
// Scale message text
messageScale := float32(28) / float32(baseFontSize)
p.message.W = int32(float32(p.message.W) * messageScale)
p.message.H = int32(float32(p.message.H) * messageScale)
return nil
}
// HandleEvent processes SDL events and handles them appropriately for the Home page.
func (p *Home) HandleEvent(event *sdl.Event) error {
return nil
}
func (p *Home) Update(dt float32) error {
var (
progressUpdate download.DownloadStatus
ok bool
gotUpdate bool
)
// Drain the channel, keeping only the newest update.
for {
select {
case progressUpdate, ok = <-p.Context.MessageChannel:
gotUpdate = true
if !ok {
if p.downloadFailed {
p.setMessage("Download failed — restart to retry", colors.Red)
} else {
p.setMessage("Status: READY", colors.Green)
p.isDownloadComplete = true
if !p.downloadDone {
p.downloadDone = true
close(p.Context.DownloadDone)
}
}
return nil
}
default:
goto drained
}
}
drained:
if !gotUpdate {
return nil
}
if progressUpdate.Err != nil {
p.downloadFailed = true
p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red)
return nil
}
// Build the display string based on what the download package tells us.
var msg string
if progressUpdate.Max <= 0 {
// No total known yet — show the descriptive message from the package.
// This covers: "Fetching file manifest", "Checking local files",
// "Comparing checksums", "Building download list", etc.
msg = fmt.Sprintf("Status: %s", progressUpdate.Msg)
} else {
// We have a known total — show percentage.
pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100
msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct)
}
p.setMessage(msg, colors.White)
return nil
}
// setMessage deletes the old message texture and builds a new one.
func (p *Home) setMessage(text string, color sdl.Color) {
if p.message.ID != 0 {
gl.DeleteTextures(1, &p.message.ID)
p.message.ID = 0
}
newMsg, err := NewStringItem(text, p.font, color)
if err != nil {
return
}
messageScale := float32(28) / float32(p.baseFontSize)
newMsg.W = int32(float32(newMsg.W) * messageScale)
newMsg.H = int32(float32(newMsg.H) * messageScale)
p.message = newMsg
}
func (p *Home) Render() error {
baseFontSize := p.baseFontSize
sm := shaderManager.Get()
verticalMargin := int32(20)
licenseInfoMargin := int32(10)
// 1) Calculate scales
titleFontSize := int32(64)
bodyFontSize := int32(32)
titleScale := float32(titleFontSize) / float32(baseFontSize)
bodyScale := float32(bodyFontSize) / float32(baseFontSize)
// 2) Calculate dimensions (scaled)
titleW := float32(p.title.W) * titleScale
titleH := float32(p.title.H) * titleScale
titleDim := mgl32.Vec2{titleW, titleH}
versionW := float32(p.version.W) * bodyScale
versionH := float32(p.version.H) * bodyScale
versionDim := mgl32.Vec2{versionW, versionH}
promptW := float32(p.prompt.W) * bodyScale
promptH := float32(p.prompt.H) * bodyScale
promptDim := mgl32.Vec2{promptW, promptH}
// Note dimensions (already scaled in Init)
noteW := float32(p.note.W)
noteH := float32(p.note.H)
// Message dimensions (already scaled in Init/Update)
messageW := float32(p.message.W)
messageH := float32(p.message.H)
// License info dimensions (already scaled in Init)
licenseHeadingW := float32(p.licenseInfoHeading.W)
licenseHeadingH := float32(p.licenseInfoHeading.H)
var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32
if p.emailDisplay.ID != 0 {
emailDisplayW = float32(p.emailDisplay.W)
emailDisplayH = float32(p.emailDisplay.H)
}
if p.licenseKeyDisplay.ID != 0 {
licenseKeyDisplayW = float32(p.licenseKeyDisplay.W)
licenseKeyDisplayH = float32(p.licenseKeyDisplay.H)
}
// 3) Calculate total height of the vertical stack
totalH := int32(titleH) + verticalMargin +
int32(versionH) + verticalMargin +
int32(promptH) + verticalMargin +
int32(noteH)
// 4) Y-up layout: start from the TOP of the centered stack and step DOWN
top := p.Base.ScreenCenterY + totalH/2
titleY := top - int32(titleH)
versionY := titleY - verticalMargin - int32(versionH)
p.prompt.Y = versionY - verticalMargin - int32(promptH)
p.note.Y = p.prompt.Y - verticalMargin - int32(noteH)
// 5) Center X for each item
titleX := p.Base.ScreenCenterX - int32(titleW/2)
versionX := p.Base.ScreenCenterX - int32(versionW/2)
p.prompt.X = p.Base.ScreenCenterX - int32(promptW/2)
p.note.X = p.Base.ScreenCenterX - int32(noteW/2)
titlePos := mgl32.Vec2{float32(titleX), float32(titleY)}
versionPos := mgl32.Vec2{float32(versionX), float32(versionY)}
// 6) Position license info at bottom center
if p.hasLicenseInfo {
bottomMargin := int32(20)
// Calculate total height of license info block
licenseInfoTotalH := int32(licenseHeadingH)
if p.emailDisplay.ID != 0 {
licenseInfoTotalH += int32(emailDisplayH) + licenseInfoMargin
}
if p.licenseKeyDisplay.ID != 0 {
licenseInfoTotalH += int32(licenseKeyDisplayH) + licenseInfoMargin
}
// Start from bottom and work up
currentY := bottomMargin
// License key (bottom-most if it exists)
if p.licenseKeyDisplay.ID != 0 {
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
p.licenseKeyDisplay.Y = currentY
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
}
// Email
if p.emailDisplay.ID != 0 {
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
p.emailDisplay.Y = currentY
currentY += int32(emailDisplayH) + licenseInfoMargin
}
// Heading (top-most)
p.licenseInfoHeading.X = p.Base.ScreenCenterX - int32(licenseHeadingW/2)
p.licenseInfoHeading.Y = currentY
}
// 7) Position message between license info and note/prompt
if p.hasLicenseInfo {
// Calculate the top of the license info block
licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH)
if p.emailDisplay.ID != 0 {
licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin
}
if p.licenseKeyDisplay.ID != 0 {
licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin
}
// Center the message vertically between license info and note
availableSpace := p.note.Y - licenseInfoTopY - verticalMargin
p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2)
} else {
// If no license info, place message below note
p.message.Y = p.note.Y - verticalMargin - int32(messageH)
}
p.message.X = p.Base.ScreenCenterX - int32(messageW/2)
// 8) Get shader and render
textShader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("failed to fetch text shader")
}
color := colors.White
p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color)
p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color)
p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color)
p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color)
// Render message
if p.message.ID != 0 {
msgColor := colors.White
if p.isDownloadComplete {
msgColor = colors.Green
}
p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor)
}
// Render license info if it exists
if p.hasLicenseInfo {
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color)
if p.emailDisplay.ID != 0 {
p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color)
}
if p.licenseKeyDisplay.ID != 0 {
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color)
}
}
return nil
}
// Destroy cleans up page-specific resources.
func (p *Home) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.version.ID)
gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.note.ID)
gl.DeleteTextures(1, &p.message.ID)
if p.hasLicenseInfo {
gl.DeleteTextures(1, &p.licenseInfoHeading.ID)
if p.emailDisplay.ID != 0 {
gl.DeleteTextures(1, &p.emailDisplay.ID)
}
if p.licenseKeyDisplay.ID != 0 {
gl.DeleteTextures(1, &p.licenseKeyDisplay.ID)
}
}
return p.Base.Destroy()
}
+107
View File
@@ -0,0 +1,107 @@
package page
import (
"testing"
"rhemawavetech.com/rhemawave/internal/download"
)
// Regression: DownloadDone was closed twice when checkState() re-navigated
// to HOME on the DOWNLOADING_ASSETS -> READY transition. The new Home page
// saw the already-closed MessageChannel and tried to close DownloadDone
// again, causing panic: close of closed channel.
//
// Primary fix: checkState() now skips navigation when transitioning between
// states that both map to the HOME page. The downloadDone flag on Home is
// defense-in-depth.
// TestHomeDownloadDoneGuardPreventsDoubleClose verifies that the per-instance
// downloadDone flag prevents closing DownloadDone more than once, even if a
// new Home page somehow reaches the close path.
func TestHomeDownloadDoneGuardPreventsDoubleClose(t *testing.T) {
downloadDone := make(chan struct{})
// First Home closes DownloadDone.
h1 := &Home{downloadDone: false}
h1.Context = &PageCtx{DownloadDone: downloadDone}
if !h1.downloadDone {
h1.downloadDone = true
close(h1.Context.DownloadDone)
}
// Second Home with guard already set (simulates defense-in-depth).
h2 := &Home{downloadDone: true}
h2.Context = &PageCtx{DownloadDone: downloadDone}
if !h2.downloadDone {
h2.downloadDone = true
close(h2.Context.DownloadDone) // would panic without guard
}
}
// TestHomeDownloadDoneGuardUnsetWouldPanic proves that without the
// downloadDone guard, closing DownloadDone a second time panics.
func TestHomeDownloadDoneGuardUnsetWouldPanic(t *testing.T) {
downloadDone := make(chan struct{})
close(downloadDone) // pre-close
defer func() {
if r := recover(); r == nil {
t.Error("expected panic when closing already-closed channel")
}
}()
// New Home with downloadDone=false tries to close an already-closed channel.
h := &Home{downloadDone: false}
h.Context = &PageCtx{DownloadDone: downloadDone}
if !h.downloadDone {
h.downloadDone = true
close(h.Context.DownloadDone) // panics
}
}
// TestHomeDownloadFailedDoesNotClose verifies that when a download fails,
// DownloadDone is NOT closed (the error path should not signal success).
func TestHomeDownloadFailedDoesNotClose(t *testing.T) {
downloadDone := make(chan struct{})
h := &Home{
downloadFailed: true,
downloadDone: false,
}
h.Context = &PageCtx{
MessageChannel: make(chan download.DownloadStatus),
DownloadDone: downloadDone,
}
// Simulate the Update logic: on failure, downloadDone should not be set
// and DownloadDone should not be closed.
if h.downloadFailed {
// Error path: do not close DownloadDone.
} else if !h.downloadDone {
h.downloadDone = true
close(h.Context.DownloadDone)
}
select {
case <-downloadDone:
t.Error("DownloadDone should not be closed on download failure")
default:
// Correct: channel is still open.
}
}
// TestHomeDownloadDoneAlreadyClosed verifies that a Home page with
// downloadDone=true never attempts to close the channel.
func TestHomeDownloadDoneAlreadyClosed(t *testing.T) {
downloadDone := make(chan struct{})
close(downloadDone)
h := &Home{downloadDone: true}
h.Context = &PageCtx{DownloadDone: downloadDone}
// Guard should prevent any close attempt.
if !h.downloadDone {
h.downloadDone = true
close(h.Context.DownloadDone)
}
}
+459
View File
@@ -0,0 +1,459 @@
package page
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type LicenseKeyPage struct {
Base
title string
titleTextureID uint32
titleTextureWidth int32
titleTextureHeight int32
currentLicenseDisplayTextureID uint32
currentLicenseDisplayTextureWidth int32
currentLicenseDisplayTextureHeight int32
inputText string
inputTextTextureID uint32
inputTextTextureWidth int32
inputTextTextureHeight int32
// Error message handling
errorMessage string
errorTextureID uint32
errorTextureWidth int32
errorTextureHeight int32
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
isFirstTimeSetup bool
// Text input deduplication
textInputSupported bool
}
// Init performs basic page initialization and creation of static assets.
func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
p.title = "License Key"
// Get application font from database
applicationFont := "SpaceMonoNerdFont-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
if !ok {
return fmt.Errorf("unable to fetch text shader")
}
// Create title texture
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
if err != nil {
return fmt.Errorf("failed to create title texture: %w", err)
}
if titleTextureID == 0 {
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
}
p.titleTextureID = titleTextureID
p.titleTextureWidth = titleWidth
p.titleTextureHeight = titleHeight
// Load existing license key from database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
licenseKey := config.LicenseKey
if licenseKey != "" {
p.inputText = licenseKey
p.isFirstTimeSetup = false
// Create current license key display texture
currentLicenseText := fmt.Sprintf("Current License Key: %s", licenseKey)
currentLicenseTextureID, currentLicenseWidth, currentLicenseHeight, err := fm.CreateStringTexture(applicationFont, currentLicenseText, textShader)
if err != nil {
return fmt.Errorf("failed to create current license display texture: %w", err)
}
if currentLicenseTextureID == 0 {
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current license display")
}
p.currentLicenseDisplayTextureID = currentLicenseTextureID
p.currentLicenseDisplayTextureWidth = currentLicenseWidth
p.currentLicenseDisplayTextureHeight = currentLicenseHeight
} else {
p.inputText = ""
p.isFirstTimeSetup = true
}
sdl.StartTextInput()
return nil
}
// formatLicenseKey formats the input string with dashes every 4 characters
// and limits to 16 alphanumeric characters (not counting dashes)
func formatLicenseKey(input string) string {
// Remove all dashes and non-alphanumeric characters
cleaned := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, input)
// Limit to 16 characters
if len(cleaned) > 16 {
cleaned = cleaned[:16]
}
// Insert dashes every 4 characters
var result strings.Builder
for i, char := range cleaned {
if i > 0 && i%4 == 0 {
result.WriteRune('-')
}
result.WriteRune(char)
}
return result.String()
}
// HandleEvent processes SDL events and handles them appropriately for the LicenseKeyPage.
func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
actualEvent := *event
if !sdl.IsTextInputActive() {
sdl.StartTextInput()
}
switch e := actualEvent.(type) {
case *sdl.TextInputEvent:
if !p.textInputSupported {
// First TextInputEvent: the keyboard fallback already
// handled this character, so just record that text input
// is supported and skip the duplicate append.
p.textInputSupported = true
} else {
// Add the new character and reformat
p.inputText += e.GetText()
p.inputText = formatLicenseKey(p.inputText)
// Clear error when user starts typing
p.errorMessage = ""
}
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym {
case sdl.K_ESCAPE:
// Block escape during first-time setup
if p.isFirstTimeSetup {
return nil
}
// Allow escape back to settings if coming from settings
sdl.StopTextInput()
p.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN:
// Validate license key - check empty first, then length, then characters
cleaned := strings.ReplaceAll(p.inputText, "-", "")
if cleaned == "" {
p.errorMessage = "License key cannot be empty"
return nil
}
if len(cleaned) != 16 {
p.errorMessage = fmt.Sprintf("License key must be 16 characters (currently %d)", len(cleaned))
return nil
}
// Check for invalid characters
for _, r := range cleaned {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
p.errorMessage = "License key must contain only letters and numbers"
return nil
}
}
sdl.StopTextInput()
// Save license key to database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
config.LicenseKey = p.inputText
p.Context.DbPtr.Save(config)
// Write license key to plain text file for the updater
if homeDir, err := os.UserHomeDir(); err == nil {
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
if err := os.WriteFile(keyPath, []byte(p.inputText), 0644); err != nil {
logger.Error("Failed to write license key file: %v", err)
}
}
if p.isFirstTimeSetup {
// First-time setup: rewind to Welcome page
p.Context.NavigateTo(HOME)
} else {
// Coming from settings: rewind back to settings
p.Context.NavigateTo(SETTINGS)
}
case sdl.K_BACKSPACE:
if len(p.inputText) > 0 {
// Remove last character and reformat
// Strip all dashes first to get raw characters
cleaned := strings.ReplaceAll(p.inputText, "-", "")
if len(cleaned) > 0 {
cleaned = cleaned[:len(cleaned)-1]
}
p.inputText = formatLicenseKey(cleaned)
// Clear error when user edits input
p.errorMessage = ""
}
default:
// Keyboard fallback: if TextInputEvent is not supported,
// convert printable keycodes to characters directly.
// This handles kiosk systems without an input method framework.
if !p.textInputSupported {
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
p.inputText += string(ch)
p.inputText = formatLicenseKey(p.inputText)
p.errorMessage = ""
}
}
}
}
}
return nil
}
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
func (p *LicenseKeyPage) Update(dt float32) error {
// Get application font from database
applicationFont := "SpaceMonoNerdFont-Regular"
fm := fontManager.Get()
sm := shaderManager.Get()
shader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("unable to fetch text shader")
}
// Update input text texture
if len(p.inputText) > 0 {
// Create the new inputText texture
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
applicationFont,
p.inputText,
shader,
)
if err != nil {
return fmt.Errorf("unable to create new input text texture: %w", err)
}
// Delete old texture if it exists
if p.inputTextTextureID != 0 {
gl.DeleteTextures(1, &p.inputTextTextureID)
}
p.inputTextTextureID = inputTextTextureID
p.inputTextTextureWidth = inputTextTextureWidth
p.inputTextTextureHeight = inputTextTextureHeight
}
// Update error message texture
if p.errorMessage != "" {
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
applicationFont,
p.errorMessage,
shader,
)
if err != nil {
return fmt.Errorf("unable to create error text texture: %w", err)
}
// Delete old error texture if it exists
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
}
p.errorTextureID = errorTextureID
p.errorTextureWidth = errorWidth
p.errorTextureHeight = errorHeight
} else {
// Clear error texture if no error message
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
p.errorTextureID = 0
}
}
return nil
}
// Render performs page drawing logic.
func (p *LicenseKeyPage) Render() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Get base font size from database
baseFontSize := config.StandardFontSize
sm := shaderManager.Get()
// 1. Calculate Scale
titleFontSize := int32(64)
titleScale := float32(titleFontSize) / float32(baseFontSize)
currentLicenseFontSize := int32(28)
currentLicenseScale := float32(currentLicenseFontSize) / float32(baseFontSize)
padding := int32(4)
verticalMargin := int32(20)
// 2. Define dimensions
targetFontSize := int32(36)
scale := float32(targetFontSize) / float32(baseFontSize)
border := int32(3)
boxH := targetFontSize + (padding * 2)
boxW := int32(600) + (padding * 2)
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
borderBoxH := boxH + (border * 2)
borderBoxW := boxW + (border * 2)
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
titleH := float32(p.titleTextureHeight) * titleScale
titleW := float32(p.titleTextureWidth) * titleScale
titleDimensions := mgl32.Vec2{titleW, titleH}
inputTextH := float32(p.inputTextTextureHeight) * scale
inputTextW := float32(p.inputTextTextureWidth) * scale
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
var currentLicenseDisplayH, currentLicenseDisplayW float32
var currentLicenseDisplayDimensions mgl32.Vec2
if p.currentLicenseDisplayTextureID != 0 {
currentLicenseDisplayH = float32(p.currentLicenseDisplayTextureHeight) * currentLicenseScale
currentLicenseDisplayW = float32(p.currentLicenseDisplayTextureWidth) * currentLicenseScale
currentLicenseDisplayDimensions = mgl32.Vec2{currentLicenseDisplayW, currentLicenseDisplayH}
}
// Error text dimensions
errorFontSize := int32(20)
errorScale := float32(errorFontSize) / float32(baseFontSize)
var errorTextH, errorTextW float32
var errorTextDimensions mgl32.Vec2
if p.errorTextureID != 0 {
errorTextH = float32(p.errorTextureHeight) * errorScale
errorTextW = float32(p.errorTextureWidth) * errorScale
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
}
// 3. Define positions
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
boxY := p.Base.ScreenCenterY - (boxH / 2)
boxX := p.Base.ScreenCenterX - (boxW / 2)
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
titleY := borderBoxY + borderBoxH + verticalMargin
titleX := p.Base.ScreenCenterX - int32(titleW/2)
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
inputTextY := boxY + padding
inputTextX := boxX + padding
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
var currentLicenseDisplayPosition mgl32.Vec2
if p.currentLicenseDisplayTextureID != 0 {
currentLicenseDisplayY := borderBoxY - int32(currentLicenseDisplayH) - verticalMargin
currentLicenseDisplayX := p.Base.ScreenCenterX - int32(currentLicenseDisplayW/2)
currentLicenseDisplayPosition = mgl32.Vec2{float32(currentLicenseDisplayX), float32(currentLicenseDisplayY)}
}
// Error text position (below current license display to avoid overlap)
var errorTextPosition mgl32.Vec2
if p.errorTextureID != 0 {
var errorTextY int32
if p.currentLicenseDisplayTextureID != 0 {
// Position below the current license display
errorTextY = borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
} else {
// No current license display, position below the input box
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
}
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
}
// 4. Get Shaders
textShader, _ := sm.Get("text")
solidShader, _ := sm.Get("solid_color")
// 5. Draw everything
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, colors.Gray)
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, colors.DarkGray)
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, colors.White)
// Draw current license display if it exists
if p.currentLicenseDisplayTextureID != 0 {
p.Base.RenderTexture(textShader, p.currentLicenseDisplayTextureID, currentLicenseDisplayPosition, currentLicenseDisplayDimensions, colors.White)
}
if len(p.inputText) > 0 {
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, colors.White)
}
// Draw error message if it exists
if p.errorTextureID != 0 {
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
}
return nil
}
// Destroy cleans up page-specific resources.
func (p *LicenseKeyPage) Destroy() error {
gl.DeleteTextures(1, &p.titleTextureID)
// Delete current license display texture if it exists
if p.currentLicenseDisplayTextureID != 0 {
gl.DeleteTextures(1, &p.currentLicenseDisplayTextureID)
p.currentLicenseDisplayTextureID = 0
}
// Delete input text texture if it exists
if p.inputTextTextureID != 0 {
gl.DeleteTextures(1, &p.inputTextTextureID)
p.inputTextTextureID = 0
}
// Delete error texture if it exists
if p.errorTextureID != 0 {
gl.DeleteTextures(1, &p.errorTextureID)
p.errorTextureID = 0
}
sdl.StopTextInput()
return p.Base.Destroy()
}
+273
View File
@@ -0,0 +1,273 @@
package page
import (
"fmt"
"strings"
"time"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type LogViewerPage struct {
Base
titleTextureID uint32
titleTextureWidth int32
titleTextureHeight int32
lines []string
lineTextureIDs []uint32
lineTextureWidths []int32
lineTextureHeights []int32
lineColors []sdl.Color
hintTextureID uint32
hintTextureWidth int32
hintTextureHeight int32
scrollOffset int32
maxScrollOffset int32
lineHeight int32
visibleHeight int32
}
func (p *LogViewerPage) Init(ctx *PageCtx) error {
var logs []db.Log
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
logger.Error("error querying database for logs: %s", result.Error)
return fmt.Errorf("%s", result.Error)
}
// // Format and word-wrap each log entry
// for _, entry := range logs {
// formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message)
// wrapped := wrapText(formatted, 100)
// p.lines = append(p.lines, strings.Split(wrapped, "\n")...)
// }
levelColors := map[string]sdl.Color{
"DEBUG": colors.SkyBlue,
"INFO": colors.White,
"WARNING": colors.Yellow,
"ERROR": colors.RedOrange,
}
fallbackColor := colors.White
p.lineColors = make([]sdl.Color, len(p.lines))
for _, entry := range logs {
color, ok := levelColors[entry.Level]
if !ok {
color = fallbackColor
}
logTime := time.UnixMilli(entry.Timestamp)
timeStr := logTime.Format("2006-01-02 15:04")
line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
p.lines = append(p.lines, line)
p.lineColors = append(p.lineColors, color)
}
applicationFont := "SpaceMonoNerdFont-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
if !ok {
return fmt.Errorf("unable to fetch text shader")
}
// Title texture
titleID, titleW, titleH, err := fm.CreateStringTexture(applicationFont, "Application Logs", textShader)
if err != nil {
return fmt.Errorf("failed to create title texture: %w", err)
}
p.titleTextureID = titleID
p.titleTextureWidth = titleW
p.titleTextureHeight = titleH
// Line textures
p.lineTextureIDs = make([]uint32, len(p.lines))
p.lineTextureWidths = make([]int32, len(p.lines))
p.lineTextureHeights = make([]int32, len(p.lines))
for i, line := range p.lines {
if strings.TrimSpace(line) == "" {
continue
}
texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader)
if err != nil {
return fmt.Errorf("failed to create line texture: %w", err)
}
p.lineTextureIDs[i] = texID
p.lineTextureWidths[i] = w
p.lineTextureHeights[i] = h
}
// Hint texture
hintText := "Arrow Keys / PgUp/PgDn to scroll | F1|2|3 to Exit"
hintID, hintW, hintH, err := fm.CreateStringTexture(applicationFont, hintText, textShader)
if err != nil {
return fmt.Errorf("failed to create hint texture: %w", err)
}
p.hintTextureID = hintID
p.hintTextureWidth = hintW
p.hintTextureHeight = hintH
return nil
}
func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym {
case sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
case sdl.K_UP:
p.scrollOffset -= p.lineHeight
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
case sdl.K_DOWN:
p.scrollOffset += p.lineHeight
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
case sdl.K_PAGEUP:
p.scrollOffset -= p.visibleHeight
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
case sdl.K_PAGEDOWN:
p.scrollOffset += p.visibleHeight
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
case sdl.K_HOME:
p.scrollOffset = 0
case sdl.K_END:
p.scrollOffset = p.maxScrollOffset
}
}
case *sdl.MouseWheelEvent:
p.scrollOffset -= e.Y * p.lineHeight * 3
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
}
return nil
}
func (p *LogViewerPage) Update(dt float32) error {
return nil
}
func (p *LogViewerPage) Render() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
baseFontSize := config.StandardFontSize
sm := shaderManager.Get()
textShader, _ := sm.Get("text")
titleFontSize := int32(48)
titleScale := float32(titleFontSize) / float32(baseFontSize)
contentFontSize := int32(24)
contentScale := float32(contentFontSize) / float32(baseFontSize)
hintFontSize := int32(20)
hintScale := float32(hintFontSize) / float32(baseFontSize)
margin := int32(40)
lineSpacing := int32(4)
p.lineHeight = contentFontSize + lineSpacing
contentLeftX := 25
// Title
titleH := float32(p.titleTextureHeight) * titleScale
titleW := float32(p.titleTextureWidth) * titleScale
titleX := p.Base.ScreenCenterX - int32(titleW/2)
titleY := p.Base.ScreenHeight - margin - int32(titleH)
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, colors.White)
// Hint at bottom
hintH := float32(p.hintTextureHeight) * hintScale
hintW := float32(p.hintTextureWidth) * hintScale
hintX := p.Base.ScreenCenterX - int32(hintW/2)
hintY := margin
p.Base.RenderTexture(textShader, p.hintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, colors.Gray)
// Content area bounds
contentTop := titleY - margin
contentBottom := hintY + int32(hintH) + margin
p.visibleHeight = contentTop - contentBottom
// Total content height
totalContentHeight := int32(len(p.lines)) * p.lineHeight
p.maxScrollOffset = totalContentHeight - p.visibleHeight
if p.maxScrollOffset < 0 {
p.maxScrollOffset = 0
}
// Render visible lines
currentY := contentTop - p.lineHeight + p.scrollOffset
for i, line := range p.lines {
if strings.TrimSpace(line) == "" {
currentY -= p.lineHeight
continue
}
if p.lineTextureIDs[i] == 0 {
currentY -= p.lineHeight
continue
}
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
currentY -= p.lineHeight
continue
}
lineW := float32(p.lineTextureWidths[i]) * contentScale
lineH := float32(p.lineTextureHeights[i]) * contentScale
color := colors.White
if i < len(p.lineColors) {
color = p.lineColors[i]
}
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, color)
currentY -= p.lineHeight
}
return nil
}
func (p *LogViewerPage) Destroy() error {
gl.DeleteTextures(1, &p.titleTextureID)
gl.DeleteTextures(1, &p.hintTextureID)
for _, texID := range p.lineTextureIDs {
if texID != 0 {
gl.DeleteTextures(1, &texID)
}
}
return p.Base.Destroy()
}
+236
View File
@@ -0,0 +1,236 @@
// Package page provides the interface for pages
package page
import (
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// Page defines the contract for all application screens.
type Page interface {
Init(ctx *PageCtx) error
HandleEvent(event *sdl.Event) error
Update(dt float32) error
Render() error
Destroy() error
}
// Base is a helper struct that pages can embed to get common functionality.
type Base struct {
Context *PageCtx
QuadVAO uint32
QuadVBO uint32
Projection mgl32.Mat4
ScreenWidth int32
ScreenHeight int32
ScreenCenterX int32
ScreenCenterY int32
}
// Init stores the application context and sets up common resources.
func (p *Base) Init(ctx *PageCtx) error {
p.Context = ctx
vertices := []float32{
// pos(x,y) tex(u,v)
0.0, 1.0, 0.0, 1.0,
1.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 1.0,
1.0, 1.0, 1.0, 1.0,
1.0, 0.0, 1.0, 0.0,
}
W := p.Context.W
H := p.Context.H
p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1)
p.ScreenHeight = H
p.ScreenWidth = W
p.ScreenCenterY = H / 2
p.ScreenCenterX = W / 2
gl.GenVertexArrays(1, &p.QuadVAO)
gl.GenBuffers(1, &p.QuadVBO)
gl.BindVertexArray(p.QuadVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.QuadVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
stride := int32(4 * 4) // Each vertex has 4 floats (pos.x, pos.y, tex.u, tex.v)
// Attribute 0: Vertex Position (vec2)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, stride, 0)
// Attribute 1: Texture Coordinate (vec2)
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, stride, 2*4) // Offset by 2 floats
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
gl.Disable(gl.BLEND)
gl.UseProgram(0)
return nil
}
// Destroy cleans up the common OpenGL resources.
func (p *Base) Destroy() error {
gl.DeleteBuffers(1, &p.QuadVBO)
gl.DeleteVertexArrays(1, &p.QuadVAO)
return nil
}
// BeginTextureRendering sets up shared GL state for a batch of RenderTexture calls.
// Call this once before a series of RenderTexture calls, then EndTextureRendering after.
func (p *Base) BeginTextureRendering(shader *shaderManager.Shader) {
gl.UseProgram(shader.ProgramID)
gl.Uniform1i(shader.LocTexture, 0)
gl.ActiveTexture(gl.TEXTURE0)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.BindVertexArray(p.QuadVAO)
}
// EndTextureRendering restores GL state after a batch of RenderTexture calls.
func (p *Base) EndTextureRendering() {
gl.BindVertexArray(0)
gl.Disable(gl.BLEND)
gl.UseProgram(0)
}
// RenderTexture draws a given texture to the screen at a specified position and size.
// For best performance, wrap multiple calls with BeginTextureRendering/EndTextureRendering.
// This method also works standalone for one-off renders.
func (p *Base) RenderTexture(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) error {
gl.UseProgram(shader.ProgramID)
gl.Uniform1i(shader.LocTexture, 0)
if shader.LocTextColor == -1 {
return fmt.Errorf("could not find 'u_textColor' uniform in shader program %d", shader.ProgramID)
}
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
mvp := p.Projection.Mul4(model)
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, textureID)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.BindVertexArray(p.QuadVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 6)
gl.BindVertexArray(0)
gl.Disable(gl.BLEND)
gl.UseProgram(0)
return nil
}
// RenderTextureBatched is a lightweight version of RenderTexture for use inside
// a BeginTextureRendering/EndTextureRendering block. It skips redundant state setup.
func (p *Base) RenderTextureBatched(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) {
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
mvp := p.Projection.Mul4(model)
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
gl.BindTexture(gl.TEXTURE_2D, textureID)
gl.DrawArrays(gl.TRIANGLES, 0, 6)
}
// RenderSolidColorQuad draws a rectangle of a solid color to the screen.
func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size mgl32.Vec2, color sdl.Color) error {
gl.UseProgram(shader.ProgramID)
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
mvp := p.Projection.Mul4(model)
if shader.LocMVPMatrix == -1 {
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_mvpMatrix' uniform")
}
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
if shader.LocColor == -1 {
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_color' uniform")
}
gl.Uniform4f(shader.LocColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.BindVertexArray(p.QuadVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 6)
gl.BindVertexArray(0)
gl.Disable(gl.BLEND)
gl.UseProgram(0)
return nil
}
// Provide default implementations for the Page interface.
func (p *Base) HandleEvent(event sdl.Event) error { return nil }
func (p *Base) Update(dt float32) error { return nil }
func (p *Base) Render() error { return nil }
// --- Helper Functions ---
// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state.
// Returns 0 for non-printable keys.
func keycodeToChar(sym sdl.Keycode, mod uint16) rune {
shifted := mod&sdl.KMOD_SHIFT != 0
// Letters
if sym >= sdl.K_a && sym <= sdl.K_z {
ch := rune(sym)
if shifted {
ch -= 'a' - 'A'
}
return ch
}
// Digits and their shifted symbols
if sym >= sdl.K_0 && sym <= sdl.K_9 {
if shifted {
shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('}
return shiftDigits[sym-sdl.K_0]
}
return rune(sym)
}
// Common punctuation / symbols
type charPair struct{ normal, shifted rune }
punctuation := map[sdl.Keycode]charPair{
sdl.K_SPACE: {' ', ' '},
sdl.K_MINUS: {'-', '_'},
sdl.K_EQUALS: {'=', '+'},
sdl.K_LEFTBRACKET: {'[', '{'},
sdl.K_RIGHTBRACKET: {']', '}'},
sdl.K_BACKSLASH: {'\\', '|'},
sdl.K_SEMICOLON: {';', ':'},
sdl.K_QUOTE: {'\'', '"'},
sdl.K_BACKQUOTE: {'`', '~'},
sdl.K_COMMA: {',', '<'},
sdl.K_PERIOD: {'.', '>'},
sdl.K_SLASH: {'/', '?'},
}
if pair, ok := punctuation[sym]; ok {
if shifted {
return pair.shifted
}
return pair.normal
}
return 0
}
+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
}
@@ -0,0 +1,793 @@
package page
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"unicode"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
"rhemawavetech.com/rhemawave/internal/sync"
)
// Scrollbar Visuals
var (
colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30}
colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100}
)
type FlatItem struct {
Label StringItem
ID string
IsCategory bool
IsSubCategory bool
Depth int
Expanded bool
SearchText string // lowercase name + content, pre-built at load time
}
type ProclamationOptions struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
// Browse Tree State
allItems []*FlatItem
activeMap map[string]bool
highlightIndex int
// Browse Scrolling State
scrollOffset int
maxVisible int
// Scrollbar Dimens
scrollTrackWidth int32
scrollTrackX int32
scrollTrackHeight int32
scrollTrackY int32
indentSize float32
verticalMargin int32
// Search State
isSearchActive bool
searchQuery string
searchInput StringItem
searchItems []*FlatItem // filtered view — pointers into allProclamations
searchHighlight int
searchScrollOff int
// Full flat proclamation pool (pointers shared with allItems leaves)
allProclamations []*FlatItem
// Spinning triangle cursor — same shape/animation as AudioOptions —
// that points at whichever row is currently highlighted.
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
triVisible bool
spinElapsed float32
}
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
p.verticalMargin = 25
p.indentSize = 40.0
p.activeMap = make(map[string]bool)
p.allItems = []*FlatItem{}
p.scrollOffset = 0
p.isSearchActive = false
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
var err error
// Syncronize proclamations first
var allProcs []db.Proclamation
allProcs, err = sync.SyncProclamations(ctx.DbPtr)
if err != nil {
// sync failed, fallback to database
ctx.DbPtr.Find(&allProcs)
}
sync.SyncFrequencies(ctx.DbPtr)
fm := fontManager.Get()
font, _ := fm.GetFont("SpaceMonoNerdFont-Regular")
p.font = font
sm := shaderManager.Get()
p.textShader, _ = sm.Get("text")
p.solidShader, _ = sm.Get("solid_color")
p.Base.Init(ctx)
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
p.title.Scale(float32(64) / float32(config.StandardFontSize))
p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White)
p.prompt.Scale(itemScale * 0.8)
// Calculation for visible area
areaHeight := float32(p.ScreenHeight) - 250
p.maxVisible = int(areaHeight / 47)
// Load current selections
var selected []db.Proclamation
ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
for _, s := range selected {
if s.ID == defaultProclamationID {
logger.Debug("Found default proclamation in selected list: %s", s.ID)
}
p.activeMap[s.ID] = true
}
procContentMap := make(map[string]string, len(allProcs))
for _, pr := range allProcs {
if pr.ID == defaultProclamationID {
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
}
procContentMap[pr.ID] = pr.Content
}
p.allProclamations = []*FlatItem{}
for _, pr := range allProcs {
fi := p.createFlatItem(pr.Name, pr.ID, false, 0)
fi.SearchText = strings.ToLower(pr.Name)
p.allProclamations = append(p.allProclamations, fi)
}
// Load Root Categories
var parentCategories []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories)
for _, c := range parentCategories {
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
}
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords.
// Mirrors the spinning cursor used in AudioOptions.
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
-0.5, -0.5,
}
gl.GenVertexArrays(1, &p.triVAO)
gl.GenBuffers(1, &p.triVBO)
gl.BindVertexArray(p.triVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.layout()
return nil
}
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
text := name
if isCat && depth == 0 {
text = "[+] " + name
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
lbl, _ := NewStringItem(text, p.font, colors.White)
lbl.Scale(itemScale)
return &FlatItem{
Label: lbl,
ID: id,
IsCategory: isCat,
IsSubCategory: isCat && depth > 0,
Depth: depth,
}
}
func (p *ProclamationOptions) layout() {
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = 40
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.ScreenHeight - 60
startY := float32(p.ScreenHeight - 150)
p.scrollTrackY = 130
p.scrollTrackHeight = int32(startY) - 130 + 50
// Reserve room on the left of the list for the spinning cursor
// (triangleSize wide, cursorGap away from the text it points at),
// ahead of the original left margin used for list rows.
cursorGap := int32(12)
listBaseX := int32(60) + int32(triangleSize) + cursorGap
if p.isSearchActive {
// Position search bar label
p.searchInput.X = 60
p.searchInput.Y = 100
// Hide all browse items
for _, item := range p.allItems {
item.Label.X = -5000
}
// Position filtered search results
for i, item := range p.searchItems {
item.Label.X = -5000
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
visibleIdx := i - p.searchScrollOff
item.Label.X = listBaseX
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
}
}
// Point the cursor at the highlighted search result, if it's
// currently scrolled into view.
p.triVisible = false
if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) &&
p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
target := p.searchItems[p.searchHighlight].Label
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(target.Y) + float32(target.H)/2
p.triVisible = true
}
return
}
// Browse mode
for i, item := range p.allItems {
item.Label.X = -5000
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
visibleIdx := i - p.scrollOffset
item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
}
}
// Point the cursor at the highlighted browse row, if it's currently
// scrolled into view. Since target.X already includes the row's depth
// indentation, the cursor shifts in and out with it automatically —
// the gap between cursor and text stays constant regardless of depth.
p.triVisible = false
if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) &&
p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
target := p.allItems[p.highlightIndex].Label
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(target.Y) + float32(target.H)/2
p.triVisible = true
}
}
func (p *ProclamationOptions) toggleExpansion(index int) {
item := p.allItems[index]
if !item.IsCategory || item.IsSubCategory {
return
}
pureName := item.Label.content[4:]
gl.DeleteTextures(1, &item.Label.ID)
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
if item.Expanded {
p.collapse(index)
item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White)
} else {
p.expand(index)
item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White)
}
item.Label.Scale(itemScale)
item.Expanded = !item.Expanded
p.layout()
}
func (p *ProclamationOptions) expand(index int) {
parent := p.allItems[index]
parentID, _ := strconv.Atoi(parent.ID)
var newItems []*FlatItem
// Load subcategories
var cats []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
for _, c := range cats {
fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1)
fi.Expanded = true
newItems = append(newItems, fi)
}
// Load proclamations for this category
var procs []db.Proclamation
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
// Sort proclamations alphabetically by name
sort.Slice(procs, func(i, j int) bool {
return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name)
})
for _, pr := range procs {
var fi *FlatItem
// Reuse existing proclamation from allProclamations if available
for _, existing := range p.allProclamations {
if existing.ID == pr.ID {
fi = existing
break
}
}
if fi == nil {
// Safety fallback
fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1)
fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content)
p.allProclamations = append(p.allProclamations, fi)
}
fi.Depth = parent.Depth + 1
newItems = append(newItems, fi)
}
// Insert new items after parent
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
// Recurse into subcategories
i := index + 1
for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth {
if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 {
p.expand(i)
}
i++
}
}
func (p *ProclamationOptions) collapse(index int) {
depth := p.allItems[index].Depth
i := index + 1
for i < len(p.allItems) && p.allItems[i].Depth > depth {
// Do NOT delete textures here — leaves are shared with allProclamations.
// Only delete textures for category items that aren't in the proclamation pool.
if p.allItems[i].IsCategory {
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
}
i++
}
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
}
// matchesWordPrefix returns true if any word in text starts with query (case-insensitive).
func matchesWordPrefix(text, query string) bool {
query = strings.ToLower(query)
text = strings.ToLower(text)
// Split text into words (non-alphanumeric boundaries)
words := strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
for _, word := range words {
if strings.HasPrefix(word, query) {
return true
}
}
return false
}
func (p *ProclamationOptions) buildSearchList() {
p.searchItems = nil
if p.searchQuery == "" {
return
}
for _, item := range p.allProclamations {
if matchesWordPrefix(item.SearchText, p.searchQuery) {
p.searchItems = append(p.searchItems, item)
}
}
if p.searchHighlight >= len(p.searchItems) {
p.searchHighlight = 0
}
}
func (p *ProclamationOptions) rebuildSearchLabel() {
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
p.searchInput.ID = 0
}
if p.searchQuery == "" {
return
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow)
p.searchInput.Scale(itemScale * 1.1)
}
func (p *ProclamationOptions) enterSearchMode(firstRune rune) {
p.searchQuery = string(firstRune)
p.isSearchActive = true
p.searchScrollOff = 0
p.searchHighlight = 0
p.buildSearchList()
p.rebuildSearchLabel()
}
func (p *ProclamationOptions) exitSearchMode() {
p.searchQuery = ""
p.isSearchActive = false
p.searchItems = nil
p.searchHighlight = 0
p.searchScrollOff = 0
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
p.searchInput.ID = 0
}
}
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
sym := e.Keysym.Sym
// --- Backspace: always handled specially ---
if sym == sdl.K_BACKSPACE {
if p.isSearchActive {
if len(p.searchQuery) > 0 {
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
}
if p.searchQuery == "" {
p.exitSearchMode()
} else {
p.buildSearchList()
p.rebuildSearchLabel()
}
p.layout()
return nil
}
// Browse mode backspace — navigate back
return nil
}
// --- Escape ---
if sym == sdl.K_ESCAPE {
if p.isSearchActive {
p.exitSearchMode()
p.layout()
return nil
}
p.Context.NavigateTo(SETTINGS)
return nil
}
// --- Search mode input handling ---
if p.isSearchActive {
switch sym {
case sdl.K_UP:
if p.searchHighlight > 0 {
p.searchHighlight--
if p.searchHighlight < p.searchScrollOff {
p.searchScrollOff--
}
}
case sdl.K_DOWN:
if p.searchHighlight < len(p.searchItems)-1 {
p.searchHighlight++
if p.searchHighlight >= p.searchScrollOff+p.maxVisible {
p.searchScrollOff++
}
}
case sdl.K_PAGEUP:
p.searchHighlight = p.searchScrollOff
p.searchScrollOff -= p.maxVisible - 1
if p.searchScrollOff < 0 {
p.searchScrollOff = 0
p.searchHighlight = 0
}
case sdl.K_PAGEDOWN:
bottom := p.searchScrollOff + p.maxVisible - 1
if bottom >= len(p.searchItems) {
bottom = len(p.searchItems) - 1
}
p.searchScrollOff = bottom
p.searchHighlight = bottom
case sdl.K_SPACE:
if len(p.searchItems) > 0 {
item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
p.acceptChanges()
case sdl.K_RETURN:
if len(p.searchItems) > 0 {
item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
p.acceptChanges()
default:
// Append printable characters to search query
r := rune(sym)
if r >= 33 && r <= 126 {
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
r = unicode.ToUpper(r)
}
p.searchQuery += string(r)
p.buildSearchList()
p.rebuildSearchLabel()
}
}
p.layout()
return nil
}
// --- Browse mode ---
switch sym {
case sdl.K_UP:
if p.highlightIndex > 0 {
p.highlightIndex--
if p.highlightIndex < p.scrollOffset {
p.scrollOffset--
}
}
case sdl.K_DOWN:
if p.highlightIndex < len(p.allItems)-1 {
p.highlightIndex++
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
}
}
case sdl.K_PAGEUP:
p.highlightIndex = p.scrollOffset
p.scrollOffset -= p.maxVisible - 1
if p.scrollOffset < 0 {
p.scrollOffset = 0
p.highlightIndex = 0
}
case sdl.K_PAGEDOWN:
bottomIdx := p.scrollOffset + p.maxVisible - 1
if bottomIdx >= len(p.allItems) {
bottomIdx = len(p.allItems) - 1
}
p.scrollOffset = bottomIdx
p.highlightIndex = bottomIdx
case sdl.K_RETURN:
item := p.allItems[p.highlightIndex]
if !item.IsCategory { // If the item is NOT a Category
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
p.acceptChanges()
} else if !item.IsSubCategory {
p.toggleExpansion(p.highlightIndex)
}
case sdl.K_SPACE:
item := p.allItems[p.highlightIndex]
if !item.IsCategory { // If the item is NOT a Category
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
p.acceptChanges()
} else if !item.IsSubCategory {
p.toggleExpansion(p.highlightIndex)
}
default:
// Any printable character enters search mode
r := rune(sym)
if r >= 33 && r <= 126 {
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
r = unicode.ToUpper(r)
}
p.enterSearchMode(r)
}
}
p.layout()
}
return nil
}
func (p *ProclamationOptions) acceptChanges() {
p.Context.DbPtr.Transaction(func(tx *gorm.DB) error {
tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false)
var ids []string
for id, active := range p.activeMap {
if active {
ids = append(ids, id)
}
}
if len(ids) > 0 {
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
logger.Debug("Saving selected state for %s\n", ids)
}
return nil
})
}
// Update advances the spinning-cursor animation. The cursor's *position* is
// recomputed in layout() whenever the highlighted row changes; this only
// drives the spin itself, frame to frame.
func (p *ProclamationOptions) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
p.spinElapsed -= 1.0
}
return nil
}
func (p *ProclamationOptions) Render() error {
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
if p.isSearchActive {
// Search bar label
if p.searchInput.ID != 0 {
p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color)
}
if len(p.searchItems) == 0 {
return nil
}
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
item := p.searchItems[i]
if p.activeMap[item.ID] {
item.Label.Color = colors.Orange
} else {
item.Label.Color = colors.White
}
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
}
// Spinning triangle cursor — always on top, points at the highlighted result
p.renderCursor()
p.renderSearchScrollbar()
return nil
}
// Browse mode
if len(p.allItems) == 0 {
return nil
}
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
item := p.allItems[i]
if item.IsCategory {
item.Label.Color = colors.DodgerBlue
} else if p.activeMap[item.ID] {
item.Label.Color = colors.Orange
} else {
item.Label.Color = colors.White
}
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
}
// Spinning triangle cursor — always on top, points at the highlighted row
p.renderCursor()
p.renderScrollbar()
return nil
}
// renderCursor draws the spinning triangle cursor pointing at whichever row
// is currently highlighted, in either browse or search mode. Identical
// shape/animation to the cursor in AudioOptions.
func (p *ProclamationOptions) renderCursor() error {
if !p.triVisible {
return nil
}
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
mvp := p.Projection.Mul4(model)
gl.UseProgram(p.solidShader.ProgramID)
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
gl.Uniform4f(
p.solidShader.LocColor,
float32(colors.White.R)/255.0,
float32(colors.White.G)/255.0,
float32(colors.White.B)/255.0,
float32(colors.White.A)/255.0,
)
gl.BindVertexArray(p.triVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 3)
gl.BindVertexArray(0)
gl.UseProgram(0)
return nil
}
func (p *ProclamationOptions) renderScrollbar() {
total := len(p.allItems)
if total <= p.maxVisible {
return
}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
colorScrollTrack)
visibleRatio := float32(p.maxVisible) / float32(total)
scrollRatio := float32(p.scrollOffset) / float32(total)
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
if thumbHeight < 20 {
thumbHeight = 20
}
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
colorScrollThumb)
}
func (p *ProclamationOptions) renderSearchScrollbar() {
total := len(p.searchItems)
if total <= p.maxVisible {
return
}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
colorScrollTrack)
visibleRatio := float32(p.maxVisible) / float32(total)
scrollRatio := float32(p.searchScrollOff) / float32(total)
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
if thumbHeight < 20 {
thumbHeight = 20
}
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
colorScrollThumb)
}
func (p *ProclamationOptions) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
}
// Delete browse category textures
for _, item := range p.allItems {
if item.IsCategory {
gl.DeleteTextures(1, &item.Label.ID)
}
}
// Delete proclamation textures via the pool (single source of truth)
for _, item := range p.allProclamations {
gl.DeleteTextures(1, &item.Label.ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package page
import (
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/download"
)
type pageType int
const (
HOME pageType = iota
SCROLLER
SETTINGS
WIFI
AUDIO
PROCLAMATIONS
LICENSE_KEY
EMAIL_ADDRESS
LOGS
MESSAGE
DOWNLOADING_ASSETS
)
type PageCtx struct {
DbPtr *gorm.DB
W, H int32
MessageChannel chan download.DownloadStatus
DownloadDone chan struct{} // closed when downloads finish (success or failure)
NavigateTo func(pageType)
Msg string // Message to be displayed by MessagePage
Inst string // Instructions to be displayed on MessagePage
}
type Router struct {
ActivePage Page
Context PageCtx
}
func NewRouter(pgCtx PageCtx) *Router {
r := &Router{
Context: pgCtx,
}
r.Context.NavigateTo = r.NavigateTo
r.NavigateTo(HOME)
return r
}
func (r *Router) NavigateTo(p pageType) {
if r.ActivePage != nil {
r.ActivePage.Destroy()
}
switch p {
case HOME:
r.ActivePage = &Home{}
case SCROLLER:
r.ActivePage = &ScrollerPage{}
case SETTINGS:
r.ActivePage = &Settings{}
case WIFI:
r.ActivePage = &WiFiSetupPage{}
case AUDIO:
r.ActivePage = &AudioOptions{}
case PROCLAMATIONS:
r.ActivePage = &ProclamationOptions{}
case LICENSE_KEY:
r.ActivePage = &LicenseKeyPage{}
case EMAIL_ADDRESS:
r.ActivePage = &EmailAddressPage{}
case LOGS:
r.ActivePage = &LogViewerPage{}
case MESSAGE:
r.ActivePage = &MessagePage{}
case DOWNLOADING_ASSETS:
r.ActivePage = &DownloadingAssets{}
}
r.ActivePage.Init(&r.Context)
}
+829
View File
@@ -0,0 +1,829 @@
package page
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
const (
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 20
// defaultProclamationPlayCount int = 1
)
type ScrollerPage struct {
Base
streams []proclamationStream
streamIndex int
// currentAudioFreq is the solfeggio Hz value of whatever track is
// currently playing. 0 means "no specific frequency" (either the
// legacy Selected-AudioFile track, or nothing yet).
currentAudioFreq int
IsDownloadingAudio bool
lines *LineDeque
lineHeight int32
shader *shaderManager.Shader
activePattern linePattern.LinePattern
font *fontManager.FontEntry
spaceWidth int32
lineCtx *linePattern.LineRenderContext
colorPattern *colorPattern.ColorPattern
scrollOffset float32
scrollY float32
fontScale float32
// Reuse one GL texture per unique word.
wordTextureCache map[string]StringItem
increment float32
velocity float32
// Time-based velocity control
velocityFloor float32
velocityCeiling float32
velocityStep float32
velocityDelay float32
velocityDirection int
velocityTimer float32
velocityState int
autoVelocity bool
hold float32
// Color palette settings
useColorPalettes bool
}
type ScrollerProclamation struct {
db.Proclamation
colorPattern.ColorPattern
ColorPalette []sdl.Color
}
type Line struct {
words []StringItem
width int32
color sdl.Color // Color for the entire line
}
// proclamationStream tracks one proclamation and how many full loops it should play.
type proclamationStream struct {
name string
words []string
repeat int
completed int
wordIndex int
palette []sdl.Color // Color palette for this proclamation
colorIndex int // Track which color to use next
// frequencyValue is the solfeggio Hz value that should be audible while
// this stream plays. 0 means "no specific frequency" (the default
// proclamation) — entering this stream never changes the audio.
frequencyValue int
}
// LineDeque is a double-ended queue for Line values backed by a circular buffer.
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
type LineDeque struct {
buf []Line
head, tail int
count int
}
func NewLineDeque(cap int) *LineDeque {
if cap < 4 {
cap = 4
}
return &LineDeque{buf: make([]Line, cap)}
}
func (d *LineDeque) Len() int { return d.count }
func (d *LineDeque) grow() {
newCap := len(d.buf) * 2
newBuf := make([]Line, newCap)
for i := 0; i < d.count; i++ {
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
}
d.buf = newBuf
d.head = 0
d.tail = d.count
}
func (d *LineDeque) PushBack(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.buf[d.tail] = l
d.tail = (d.tail + 1) % len(d.buf)
d.count++
}
func (d *LineDeque) PushFront(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
d.buf[d.head] = l
d.count++
}
func (d *LineDeque) PopFront() Line {
l := d.buf[d.head]
d.buf[d.head] = Line{} // clear for GC
d.head = (d.head + 1) % len(d.buf)
d.count--
return l
}
func (d *LineDeque) PopBack() Line {
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
l := d.buf[d.tail]
d.buf[d.tail] = Line{} // clear for GC
d.count--
return l
}
func (d *LineDeque) Front() Line { return d.buf[d.head] }
func (d *LineDeque) Back() Line {
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
}
func (d *LineDeque) Get(i int) *Line {
return &d.buf[(d.head+i)%len(d.buf)]
}
func (d *LineDeque) Set(i int, l Line) {
d.buf[(d.head+i)%len(d.buf)] = l
}
func (d *LineDeque) Clear() {
for i := 0; i < d.count; i++ {
d.buf[(d.head+i)%len(d.buf)] = Line{}
}
d.head, d.tail, d.count = 0, 0, 0
}
func (p *ScrollerPage) Init(ctx *PageCtx) error {
p.increment = 100.0
p.velocity = 800.0
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
p.useColorPalettes = true // Default to enabled
sm := shaderManager.Get()
fm := fontManager.Get()
// Get font
font, ok := fm.GetFont("Scrambled")
if !ok {
return fmt.Errorf("unable to fetch font 'Scrambled'")
}
p.font = font
logger.Debug("Font name: %s", font.Name)
p.Base.Init(ctx)
// Get text shader
textShader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("text shader not found")
}
p.shader = textShader
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
p.wordTextureCache = make(map[string]StringItem, 256)
// Load proclamation streams from database
if err := p.loadProclamations(); err != nil {
logger.Error("Error loading proclamations: %v", err)
p.Context.NavigateTo(PROCLAMATIONS)
return nil
}
// Get line pattern from database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
linePatternName := config.LinePattern
p.activePattern, err = linePattern.Get(linePatternName)
if err != nil {
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
}
// Initialize color pattern
baseColors := []sdl.Color{
colors.Tan,
colors.PeachPuff,
colors.LightCyan,
colors.LightSeaGreen,
colors.MediumTurquoise,
colors.DarkCyan,
colors.MediumPurple,
}
p.colorPattern = colorPattern.NewColorPattern(baseColors)
// Font setup
p.fontScale = float32(32) / float32(config.StandardFontSize)
nLines := int(float32(p.ScreenHeight) / 32.0)
p.lines = NewLineDeque(nLines * 2)
// Calculate space width using the scale ratio.
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
if !ok {
return fmt.Errorf("failed to get space glyph")
}
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
// Get line height using the same scale ratio.
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
if err != nil {
return fmt.Errorf("failed to create sample texture: %w", err)
}
p.lineHeight = int32(float32(H) * p.fontScale)
gl.DeleteTextures(1, &tmpStrID)
p.lineCtx = &linePattern.LineRenderContext{
ScreenWidth: p.ScreenWidth,
Font: font,
FontScale: p.fontScale,
SpaceWidth: p.spaceWidth,
}
// Create initial lines
for i := range nLines {
if err := p.createLine(i, true); err != nil {
return fmt.Errorf("failed to create initial line %d: %w", i, err)
}
}
// Start audio for the first proclamation in the stream. Falls back to
// whatever track is marked Selected in the audio_files table if the
// first stream has no frequency (e.g. only the default proclamation is
// queued) or its matching file doesn't exist on disk yet.
started := false
if len(p.streams) > 0 {
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
}
if !started {
var selectedAudio db.AudioFile
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
selectedAudio = db.AudioFile{
Name: "OriginalAudio",
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
Selected: true,
Order: 0,
}
}
logger.Info("Playing %s", selectedAudio.Path)
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
logger.Error("Mixer returned an error: %v", err)
}
}
currentDevice := mixer.CurrentDevice()
if currentDevice == "" {
currentDevice = "default"
}
_ = currentDevice
return nil
}
// loadProclamations loads selected proclamations as stream units (not one giant word list).
func (p *ScrollerPage) loadProclamations() error {
p.streams = p.streams[:0]
// Load all solfeggio frequencies
var frequencies []db.SolfeggioFrequency
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
}
// Create a map of frequency ID to its value
frequencyMap := make(map[int]int)
for _, freq := range frequencies {
frequencyMap[freq.ID] = freq.Value
}
// Map frequency values to their color palettes
frequencyPalettes := map[int][]sdl.Color{
396: colors.Freq_396_Colors[:],
417: colors.Freq_417_Colors[:],
528: colors.Freq_528_Colors[:], // Default proclamation
639: colors.Freq_639_Colors[:],
741: colors.Freq_741_Colors[:],
852: colors.Freq_852_Colors[:],
963: colors.Freq_963_Colors[:],
}
// Load selected proclamations
var selected []db.Proclamation
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
return err
}
// Load default proclamation
var defaultProclamation db.Proclamation
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
return err
}
defaultWords := strings.Fields(defaultProclamation.Content)
// Build interleaved stream:
// each selected proclamation repeats N times, then default repeats once.
for _, proclamation := range selected {
// Skip default proclamation if it's in the selected list
if proclamation.ID == defaultProclamationID {
continue
}
words := strings.Fields(proclamation.Content)
if len(words) == 0 {
continue
}
// Get the frequency value for this proclamation
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
if !ok {
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
freqValue = 528 // Default to 528 if frequency not found
}
// Get the palette for this frequency
palette, ok := frequencyPalettes[freqValue]
if !ok {
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
palette = colors.Freq_528_Colors[:] // Fallback to default palette
}
p.streams = append(p.streams, proclamationStream{
name: proclamation.Name,
words: words,
repeat: proclamationPlayCount,
palette: palette,
colorIndex: 0,
frequencyValue: freqValue,
})
if len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
colorIndex: 0,
frequencyValue: 0, // default proclamation never changes audio
})
}
}
// Fallback: if no non-default selected proclamations, at least play default.
if len(p.streams) == 0 && len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
palette: colors.Freq_528_Colors[:],
colorIndex: 0,
frequencyValue: 0,
})
}
if len(p.streams) == 0 {
return fmt.Errorf("failed to load proclamations: no proclamation words available")
}
p.streamIndex = 0
return nil
}
// audioDir returns the path to the audio files directory (~/.radiantwave/audio).
func audioDir() string {
home, err := os.UserHomeDir()
if err != nil {
panic(err)
}
return filepath.Join(home, ".radiantwave", "audio")
}
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav".
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq))
}
// switchAudioForFrequency swaps the currently playing track to match freq.
//
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
// always a no-op — whatever is playing keeps playing.
// - If freq already matches what's playing, nothing happens.
// - If the matching file doesn't exist on disk yet, the current track
// keeps playing untouched (so missing solfeggio audio never causes
// silence — and starts working automatically once the file is added).
// - Otherwise the current track is stopped immediately and freq's track
// starts right away (hard cut, no fade).
//
// Returns true if freq's track is now playing (either just switched, or
// already was).
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
if freq == 0 {
return false
}
if freq == p.currentAudioFreq {
return true
}
path := p.audioPathForFrequency(freq)
if _, err := os.Stat(path); err != nil {
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
return false
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
mixer.Stop(3000, config)
if err := mixer.Play(path, true, config); err != nil {
logger.Error("Mixer returned an error: %v", err)
return false
}
p.currentAudioFreq = freq
return true
}
// nextLineWords gets words for the current line and advances proclamation stream when needed.
func (p *ScrollerPage) nextLineWords() []string {
if len(p.streams) == 0 {
return nil
}
s := &p.streams[p.streamIndex]
if len(s.words) == 0 {
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
return p.nextLineWords()
}
prevIdx := s.wordIndex
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
// If word index wraps, one full proclamation pass completed.
if s.wordIndex < prevIdx {
s.completed++
targetRepeats := s.repeat
if targetRepeats <= 0 {
targetRepeats = 1
}
if s.completed >= targetRepeats {
s.completed = 0
s.wordIndex = 0
s.colorIndex = 0 // Reset color index when proclamation restarts
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
next := &p.streams[p.streamIndex]
p.switchAudioForFrequency(next.frequencyValue)
}
}
return words
}
// getCachedWord returns a reusable textured StringItem for a word.
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
if cached, ok := p.wordTextureCache[word]; ok {
return cached, nil
}
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
}
base.Scale(p.fontScale)
p.wordTextureCache[word] = base
return base, nil
}
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
line := Line{words: []StringItem{}}
// Get the current proclamation's color palette
var lineColor sdl.Color
if p.useColorPalettes && len(p.streams) > 0 {
currentStream := &p.streams[p.streamIndex]
if len(currentStream.palette) > 0 {
// Use the current color from the palette
lineColor = currentStream.palette[currentStream.colorIndex]
// Increment color index for the next line
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
} else {
// Fallback to default color if no palette is found
lineColor = p.colorPattern.Color()
}
} else {
// Use the default color pattern if useColorPalettes is false
lineColor = p.colorPattern.Color()
}
for _, word := range words {
if word == "" {
continue
}
cached, err := p.getCachedWord(word)
if err != nil {
return Line{}, err
}
item := cached
item.Color = lineColor // Assign the line color to all words in the line
if len(line.words) > 0 {
line.width += p.spaceWidth
}
line.words = append(line.words, item)
line.width += item.W
}
line.color = lineColor // Store the line color
return line, nil
}
// positionLine sets X/Y for all words in a line.
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
startX := p.ScreenCenterX - (line.width / 2)
for j := range line.words {
if j == 0 {
line.words[j].X = startX
} else {
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
}
line.words[j].Y = baseY
}
}
// createLine is a helper to generate a new line at a specific index.
// isInitial determines if this is during initialization (positions from top)
// or dynamic creation (positions relative to existing lines).
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
words := p.nextLineWords()
line, err := p.buildLineFromWords(words)
if err != nil {
return err
}
var baseY int32
if isInitial {
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
} else {
baseY = 0
}
p.positionLine(&line, baseY)
if isInitial {
p.lines.PushBack(line)
}
return nil
}
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
// switch e := (*event).(type) {
// case *sdl.KeyboardEvent:
// if e.Type != sdl.KEYDOWN {
// return nil
// }
// switch e.Keysym.Sym {
// case sdl.K_UP:
// if !p.autoVelocity {
// p.velocity += p.increment
// }
// case sdl.K_DOWN:
// if !p.autoVelocity {
// p.velocity -= p.increment
// }
// }
// return nil
// }
return nil
}
func (p *ScrollerPage) Update(dt float32) error {
p.updateAutoVelocity(dt)
p.scrollOffset += dt * p.velocity
move := int32(p.scrollOffset)
if move == 0 {
return nil
}
p.scrollOffset -= float32(move)
// Move all lines vertically
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for j := range line.words {
line.words[j].Y += move
}
}
// Handle line replacement based on scroll direction
if p.velocity > 0 {
// Moving up: replace lines that go off top
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
if err := p.addLineAtBottom(); err != nil {
return err
}
}
} else if p.velocity < 0 {
// Moving down: replace lines that go off bottom
for p.lines.Len() > 0 {
back := p.lines.Back()
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
break
}
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
if err := p.addLineAtTop(); err != nil {
return err
}
}
}
return nil
}
// addLineAtBottom creates and adds a new line at the bottom of the display.
func (p *ScrollerPage) addLineAtBottom() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
} else {
baseY = -int32(p.lineHeight)
}
p.positionLine(&line, baseY)
p.lines.PushBack(line)
return nil
}
// addLineAtTop creates and adds a new line at the top of the display.
func (p *ScrollerPage) addLineAtTop() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
} else {
baseY = p.ScreenHeight
}
p.positionLine(&line, baseY)
p.lines.PushFront(line)
return nil
}
func (p *ScrollerPage) Render() error {
p.BeginTextureRendering(p.shader)
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for _, word := range line.words {
if word.ID == 0 {
continue
}
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
}
}
p.EndTextureRendering()
return nil
}
func (p *ScrollerPage) Destroy() error {
if p == nil {
return nil
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
mixer.Stop(3000, config)
// Delete each cached word texture exactly once.
for k, item := range p.wordTextureCache {
if item.ID != 0 {
id := item.ID
gl.DeleteTextures(1, &id)
}
delete(p.wordTextureCache, k)
}
gl.Flush()
// Drop references for GC safely
p.lines = nil
p.streams = nil
p.lineCtx = nil
p.font = nil
p.shader = nil
p.colorPattern = nil
p.wordTextureCache = nil
return nil
}
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
p.autoVelocity = enabled
p.velocityFloor = -float32(math.Abs(float64(floor)))
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
p.velocityStep = float32(math.Abs(float64(step)))
p.velocityDelay = delay
p.hold = hold
// Initialize state
p.velocityDirection = 1
p.velocityState = 0
p.velocityTimer = 0.0
p.velocity = p.velocityFloor
}
// SetUseColorPalettes enables or disables the use of color palettes for lines.
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
p.useColorPalettes = enabled
}
// updateAutoVelocity handles automatic velocity changes.
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
if !p.autoVelocity {
return
}
switch p.velocityState {
case 0: // Changing velocity
if p.velocityDirection == 1 {
p.velocity += p.velocityStep * dt
if p.velocity >= p.velocityCeiling {
p.velocity = p.velocityCeiling
p.velocityState = 1
p.velocityTimer = 0.0
}
} else {
p.velocity -= p.velocityStep * dt
if p.velocity <= p.velocityFloor {
p.velocity = p.velocityFloor
p.velocityState = 2
p.velocityTimer = 0.0
}
}
case 1: // Waiting at ceiling
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = -1
p.velocityState = 0
}
case 2: // Waiting at floor
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = 1
p.velocityState = 0
}
}
}
+255
View File
@@ -0,0 +1,255 @@
package page
import (
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type Settings struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
options []Option
verticalMargin int32
bgHighlight PageItem
highlightIndex int
}
type Option struct {
target pageType
label StringItem
}
func (p *Settings) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
// Get font
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
// Get shaders
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
}
p.solidShader, ok = sm.Get("solid_color")
if !ok {
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
}
// Initialize base
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
// Create title
p.title, err = NewStringItem("Settings", font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
// Create prompt
p.prompt, err = NewStringItem("Use Arrow Keys to Navigate, Enter to Select, or F1 to return to the Main Menu", font, colors.White)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
// Create options
proclamationsLabel, err := NewStringItem("Proclamations", font, colors.White)
if err != nil {
return fmt.Errorf("creating proclamations label: %w", err)
}
p.options = append(p.options, Option{
target: PROCLAMATIONS,
label: proclamationsLabel,
})
audioLabel, err := NewStringItem("Audio", font, colors.White)
if err != nil {
return fmt.Errorf("creating audio label: %w", err)
}
p.options = append(p.options, Option{
target: AUDIO,
label: audioLabel,
})
emailLabel, err := NewStringItem("Email Address", font, colors.White)
if err != nil {
return fmt.Errorf("creating email label: %w", err)
}
p.options = append(p.options, Option{
target: EMAIL_ADDRESS,
label: emailLabel,
})
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White)
if err != nil {
return fmt.Errorf("creating license key label: %w", err)
}
p.options = append(p.options, Option{
target: LICENSE_KEY,
label: licenseKeyLabel,
})
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
if err != nil {
return fmt.Errorf("creating wifi label: %w", err)
}
p.options = append(p.options, Option{
target: WIFI,
label: wifiLabel,
})
logsLabel, err := NewStringItem("Logs", font, colors.White)
if err != nil {
return fmt.Errorf("creating logs label: %w", err)
}
p.options = append(p.options, Option{
target: LOGS,
label: logsLabel,
})
// TODO: Add entry for "Tutorial"
// TODO: Add entry for "About"
// Get base font size from database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
baseFontSize := config.StandardFontSize
// Calculate scales
titleScale := float32(64) / float32(baseFontSize)
scale := float32(32) / float32(baseFontSize)
// Apply scales
p.title.Scale(titleScale)
p.prompt.Scale(scale)
for i := range p.options {
p.options[i].label.Scale(scale)
}
// Create background highlight using first option's dimensions
p.bgHighlight = PageItem{
ID: 0,
W: p.options[0].label.W,
H: p.options[0].label.H,
Color: colors.White,
}
p.highlightIndex = 0
// Position title at bottom
p.title.X = p.Base.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Position prompt above title
p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2)
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
// If this value starts at 0, nothing is further left than that...
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
// Position options stacked upward from prompt
for i, option := range p.options {
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
// Then update furthestLeft
furthestLeft = p.options[i].label.X
}
if i == 0 {
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
} else {
p.options[i].label.Y = p.options[i-1].label.Y - option.label.H - 10
}
}
// Now, re-iterate over the options and justify everything to furthestLeft
for i := range p.options {
p.options[i].label.X = furthestLeft
}
return nil
}
func (p *Settings) Update(dt float32) error {
return nil
}
func (p *Settings) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
switch e.Keysym.Sym {
case sdl.K_UP:
if p.highlightIndex > 0 {
p.highlightIndex--
}
case sdl.K_DOWN:
if p.highlightIndex < len(p.options)-1 {
p.highlightIndex++
}
case sdl.K_RETURN:
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
selectedOption := p.options[p.highlightIndex]
p.Context.NavigateTo(selectedOption.target)
}
case sdl.K_BACKSPACE:
p.Context.NavigateTo(HOME)
}
}
return nil
}
func (p *Settings) Render() error {
// Render title and prompt
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
// Render highlight behind selected option
p.RenderSolidColorQuad(
p.solidShader,
p.options[p.highlightIndex].label.Position(),
p.options[p.highlightIndex].label.RawDimensions(),
p.bgHighlight.Color,
)
// Render all options with appropriate colors
for i, option := range p.options {
if p.highlightIndex == i {
option.label.Color = colors.Black
} else {
option.label.Color = colors.White
}
p.RenderTexture(p.textShader, option.label.ID, option.label.Position(), option.label.RawDimensions(), option.label.Color)
}
return nil
}
func (p *Settings) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
for i := range p.options {
gl.DeleteTextures(1, &p.options[i].label.ID)
}
return nil
}
+656
View File
@@ -0,0 +1,656 @@
package page
import (
"context"
"fmt"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/keybinds"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/network"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// ─── Types ────────────────────────────────────────────────────────────────────
type networkUIItem struct {
Net network.Network
Item StringItem
}
type wifiStatus int
const (
wifiStatusIdle wifiStatus = iota
wifiStatusScanning
wifiStatusConnecting
wifiStatusConnected
wifiStatusError
)
// ─── Page ─────────────────────────────────────────────────────────────────────
type WiFiSetupPage struct {
Base
applicationFont *fontManager.FontEntry
textInputSupported bool
// UI elements
BaseFontSize int
titleItem StringItem
scanningStatusItem StringItem
connectionStatusItem StringItem
passwordPromptItem StringItem
passwordInputItem StringItem
passwordErrorItem StringItem
// Network list
networks []networkUIItem
selectedIndex int
// State
status wifiStatus
statusMessage string
connectError string
// Password entry
isEnteringPassword bool
passwordInput string
selectedNet *network.Network
// Async
ctx context.Context
cancel context.CancelFunc
networksCh <-chan []network.Network
connectResultCh chan error
}
// ─── Init ─────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("could not find application font: SpaceMonoNerdFont-Regular")
}
p.applicationFont = font
var err error
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White)
if err != nil {
return fmt.Errorf("failed to create title item: %w", err)
}
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White)
if err != nil {
return fmt.Errorf("failed to create scanning status item: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
p.BaseFontSize = config.StandardFontSize
p.connectResultCh = make(chan error, 1)
p.ctx, p.cancel = context.WithCancel(context.Background())
p.startScan()
return nil
}
// ─── Scan ─────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) startScan() {
p.status = wifiStatusScanning
p.statusMessage = "Scanning for networks..."
p.selectedIndex = 0
p.networks = nil
var err error
p.networksCh, err = network.ScanForNetworks(p.ctx)
if err != nil {
logger.Error("wifi: failed to start scan: %v", err)
p.status = wifiStatusError
p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found."
p.rebuildStatusTexture()
}
}
// ─── Input ────────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
if p.isEnteringPassword {
p.handlePasswordKey(e.Keysym.Sym, e.Keysym.Mod)
} else {
p.handleNavKey(e.Keysym.Sym)
}
}
return nil
}
func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode, mod uint16) {
switch sym {
case sdl.K_RETURN:
p.submitPassword()
case sdl.K_BACKSPACE:
if len(p.passwordInput) > 0 {
runes := []rune(p.passwordInput)
p.passwordInput = string(runes[:len(runes)-1])
p.rebuildPasswordTexture()
}
case sdl.K_ESCAPE:
logger.Info("wifi: password entry cancelled")
p.exitPasswordMode()
p.connectError = ""
p.selectedNet = nil
default:
if ch := keycodeToChar(sym, mod); ch != 0 {
p.passwordInput += string(ch)
p.rebuildPasswordTexture()
}
}
}
func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) {
switch sym {
case sdl.K_UP:
if p.selectedIndex > 0 {
p.selectedIndex--
}
case sdl.K_DOWN:
if p.selectedIndex < len(p.networks)-1 {
p.selectedIndex++
}
case sdl.K_RETURN:
if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) {
return
}
selected := p.networks[p.selectedIndex].Net
p.selectedNet = &p.networks[p.selectedIndex].Net
p.connectError = ""
if selected.Security != network.SecurityOpen {
p.passwordInput = ""
p.enterPasswordMode()
} else {
p.connect("")
}
case sdl.K_r:
if p.status != wifiStatusScanning && p.status != wifiStatusConnecting {
p.startScan()
}
case sdl.K_ESCAPE:
p.selectedNet = nil
p.connectError = ""
}
}
func (p *WiFiSetupPage) enterPasswordMode() {
keybinds.Enabled = false
p.isEnteringPassword = true
p.textInputSupported = false
p.rebuildPasswordPromptTexture()
p.rebuildPasswordTexture()
sdl.StartTextInput()
logger.Info("wifi: password entry started for %q", p.selectedNet.SSID)
}
func (p *WiFiSetupPage) exitPasswordMode() {
keybinds.Enabled = true
p.isEnteringPassword = false
sdl.StopTextInput()
logger.Info("wifi: password entry finished")
}
func (p *WiFiSetupPage) submitPassword() {
p.exitPasswordMode()
p.connect(p.passwordInput)
}
// ─── Connect ──────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) connect(password string) {
if p.selectedNet == nil {
return
}
ssid := p.selectedNet.SSID
p.status = wifiStatusConnecting
p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid)
logger.Info("wifi: connecting to %q", ssid)
// ConnectToNetwork blocks (up to 15s), so run it off the main goroutine.
go func() {
err := network.ConnectToNetwork(ssid, password)
p.connectResultCh <- err
}()
}
// ─── Update ───────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Update(dt float32) error {
select {
case nets, ok := <-p.networksCh:
if ok {
p.rebuildNetworkList(nets)
if p.status == wifiStatusScanning {
p.status = wifiStatusIdle
p.statusMessage = ""
}
}
case err := <-p.connectResultCh:
if err != nil {
logger.Error("wifi: connection failed: %v", err)
p.status = wifiStatusError
p.connectError = fmt.Sprintf("Could not connect: %s", err.Error())
p.statusMessage = ""
// Drop back into password entry so the user can try again.
if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen {
p.passwordInput = ""
p.rebuildPasswordErrorTexture()
p.enterPasswordMode()
}
} else {
ssid := ""
if p.selectedNet != nil {
ssid = p.selectedNet.SSID
}
logger.Info("wifi: connected to %q", ssid)
p.status = wifiStatusConnected
p.connectError = ""
p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid)
// Trigger a fresh scan so the list shows the connected state.
p.startScan()
}
default:
}
p.rebuildStatusTexture()
return nil
}
// ─── Texture builders ─────────────────────────────────────────────────────────
func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) {
for _, item := range p.networks {
if item.Item.ID != 0 {
gl.DeleteTextures(1, &item.Item.ID)
}
}
p.networks = make([]networkUIItem, 0, len(nets))
for _, n := range nets {
label := n.SSID
switch {
case n.Connected:
label += " ✓"
case n.Saved:
label += " ★"
}
if n.Security != network.SecurityOpen {
var secStr string
switch n.Security {
case network.SecurityWEP:
secStr = "WEP"
case network.SecurityWPA:
secStr = "WPA"
case network.SecurityWPA2:
secStr = "WPA2"
case network.SecurityWPA3:
secStr = "WPA3"
default:
secStr = "Secured"
}
label += " [" + secStr + "]"
}
c := colors.White
if n.Connected {
c = colors.LawnGreen
}
item, err := NewStringItem(label, p.applicationFont, c)
if err != nil {
logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err)
continue
}
p.networks = append(p.networks, networkUIItem{Net: n, Item: item})
}
}
func (p *WiFiSetupPage) rebuildStatusTexture() {
if p.connectionStatusItem.ID != 0 {
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
p.connectionStatusItem.ID = 0
}
var msg string
var c sdl.Color
switch p.status {
case wifiStatusScanning:
msg = "Scanning for nearby networks, please wait..."
c = colors.White
case wifiStatusConnecting:
msg = p.statusMessage
c = colors.Yellow
case wifiStatusConnected:
msg = p.statusMessage
c = colors.LawnGreen
case wifiStatusError:
msg = p.connectError
if msg == "" {
msg = "Something went wrong. Press R to scan again."
}
c = colors.Red
default:
if len(p.networks) == 0 {
msg = "No networks found. Press R to scan again."
c = colors.Yellow
} else {
msg = "Select a network to connect — press R to rescan"
c = colors.White
}
}
if msg == "" {
return
}
item, err := NewStringItem(msg, p.applicationFont, c)
if err == nil {
p.connectionStatusItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordTexture() {
if p.passwordInputItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordInputItem.ID)
}
text := p.passwordInput
if text == "" {
text = " " // prevent empty texture
}
item, err := NewStringItem(text, p.applicationFont, colors.White)
if err == nil {
p.passwordInputItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordPromptTexture() {
if p.passwordPromptItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
}
if p.selectedNet == nil {
return
}
prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID)
item, err := NewStringItem(prompt, p.applicationFont, colors.White)
if err == nil {
p.passwordPromptItem = item
}
}
func (p *WiFiSetupPage) rebuildPasswordErrorTexture() {
if p.passwordErrorItem.ID != 0 {
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
p.passwordErrorItem.ID = 0
}
if p.connectError == "" {
return
}
item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red)
if err == nil {
p.passwordErrorItem = item
}
}
// ─── Render ───────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Render() error {
solidShader, _ := shaderManager.Get().Get("solid_color")
textShader, _ := shaderManager.Get().Get("text")
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
white := colors.White
vertMargin := float32(20)
// Background
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{0, 0},
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
bgColor,
)
// Title — 48pt
titleScale := float32(48) / float32(p.BaseFontSize)
titleW := float32(p.titleItem.W) * titleScale
titleH := float32(p.titleItem.H) * titleScale
titleX := float32(p.ScreenCenterX) - titleW/2
titleY := float32(p.ScreenHeight) - titleH - 40
p.Base.RenderTexture(textShader, p.titleItem.ID,
mgl32.Vec2{titleX, titleY},
mgl32.Vec2{titleW, titleH},
white,
)
// Status message — 30pt (was 24pt, now 1.25x)
statusY := titleY - titleH - vertMargin
if p.status == wifiStatusScanning {
scale := float32(30) / float32(p.BaseFontSize)
w := float32(p.scanningStatusItem.W) * scale
h := float32(p.scanningStatusItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID,
mgl32.Vec2{x, statusY},
mgl32.Vec2{w, h},
white,
)
} else if p.connectionStatusItem.ID != 0 {
scale := float32(30) / float32(p.BaseFontSize)
w := float32(p.connectionStatusItem.W) * scale
h := float32(p.connectionStatusItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID,
mgl32.Vec2{x, statusY},
mgl32.Vec2{w, h},
p.connectionStatusItem.Color,
)
}
// Network list
listY := statusY - vertMargin
itemH := int32(40)
listPad := int32(10)
listW := float32(700)
listX := float32(p.ScreenCenterX) - listW/2
for i, item := range p.networks {
rowY := listY - float32(int32(i+1)*itemH)
if i == p.selectedIndex {
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{listX, rowY},
mgl32.Vec2{listW, float32(itemH)},
selColor,
)
}
scale := float32(24) / float32(p.BaseFontSize)
tw := float32(item.Item.W) * scale
th := float32(item.Item.H) * scale
tx := listX + float32(listPad)
ty := rowY + (float32(itemH)/2 - th/2)
p.Base.RenderTexture(textShader, item.Item.ID,
mgl32.Vec2{tx, ty},
mgl32.Vec2{tw, th},
item.Item.Color,
)
}
// Password dialog
if p.isEnteringPassword {
if err := p.renderPasswordDialog(solidShader, textShader, float32(p.BaseFontSize)); err != nil {
return err
}
}
return nil
}
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180}
dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255}
boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255}
boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255}
white := colors.White
dialogW := int32(600)
dialogH := int32(250)
if p.connectError != "" {
dialogH = 310
}
dialogX := p.ScreenCenterX - dialogW/2
dialogY := p.ScreenCenterY - dialogH/2
// Overlay + dialog box
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{0, 0},
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
overlayColor,
)
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(dialogX), float32(dialogY)},
mgl32.Vec2{float32(dialogW), float32(dialogH)},
dialogBgColor,
)
currentY := float32(dialogY+dialogH) - 30
// Error message
if p.passwordErrorItem.ID != 0 {
scale := float32(20) / baseFontSize
w := float32(p.passwordErrorItem.W) * scale
h := float32(p.passwordErrorItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
currentY -= h
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID,
mgl32.Vec2{x, currentY},
mgl32.Vec2{w, h},
colors.Red,
)
currentY -= 20
}
// Prompt
if p.passwordPromptItem.ID != 0 {
scale := float32(24) / baseFontSize
w := float32(p.passwordPromptItem.W) * scale
h := float32(p.passwordPromptItem.H) * scale
x := float32(p.ScreenCenterX) - w/2
currentY -= h
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID,
mgl32.Vec2{x, currentY},
mgl32.Vec2{w, h},
white,
)
currentY -= 30
}
// Input box
padding := int32(4)
border := int32(3)
boxW := int32(500)
fontSize := int32(36)
boxH := fontSize + padding*2
bboxW := boxW + border*2
bboxH := boxH + border*2
bboxX := p.ScreenCenterX - bboxW/2
bboxY := int32(currentY) - boxH - padding*2
boxX := p.ScreenCenterX - boxW/2
boxY := int32(currentY) - boxH - padding
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(bboxX), float32(bboxY)},
mgl32.Vec2{float32(bboxW), float32(bboxH)},
boxBorderColor,
)
p.Base.RenderSolidColorQuad(solidShader,
mgl32.Vec2{float32(boxX), float32(boxY)},
mgl32.Vec2{float32(boxW), float32(boxH)},
boxBgColor,
)
// Password text (plain, unmasked)
if p.passwordInputItem.ID != 0 {
scale := float32(fontSize) / baseFontSize
w := float32(p.passwordInputItem.W) * scale
h := float32(p.passwordInputItem.H) * scale
p.Base.RenderTexture(textShader, p.passwordInputItem.ID,
mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)},
mgl32.Vec2{w, h},
white,
)
}
return nil
}
// ─── Destroy ──────────────────────────────────────────────────────────────────
func (p *WiFiSetupPage) Destroy() error {
keybinds.Enabled = true
sdl.StopTextInput()
// Cancel the scan context — stops ScanForNetworks goroutine.
if p.cancel != nil {
p.cancel()
}
deleteTexture := func(id *uint32) {
if *id != 0 {
gl.DeleteTextures(1, id)
*id = 0
}
}
deleteTexture(&p.titleItem.ID)
deleteTexture(&p.scanningStatusItem.ID)
deleteTexture(&p.connectionStatusItem.ID)
deleteTexture(&p.passwordPromptItem.ID)
deleteTexture(&p.passwordInputItem.ID)
deleteTexture(&p.passwordErrorItem.ID)
for _, item := range p.networks {
deleteTexture(&item.Item.ID)
}
return p.Base.Destroy()
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,199 @@
package shaderManager
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/go-gl/gl/v3.3-core/gl"
)
// 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(shaderDir string) error {
var err error
once.Do(func() {
sm := &ShaderManager{
shaders: make(map[string]*Shader),
}
if e := sm.LoadShaders(shaderDir); e != nil {
err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, 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(shaderDir string) error {
// Get assets directory from database
shaderFiles := make(map[string]string)
err := filepath.Walk(shaderDir, 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
}
+144
View File
@@ -0,0 +1,144 @@
package sync
import (
"regexp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/netreq"
)
var htmlRegex = regexp.MustCompile(`<[^>]*>`)
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
// It returns the slice of proclamations so the UI can update immediately.
func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) {
fullURL := "https://portal.radiantwavetech.com/api/v1/proclamations"
logger.Debug("Syncronizing proclamations. URL: %s", fullURL)
proclamations, err := netreq.GetJSON[db.Proclamation](fullURL)
if err != nil {
// If we're offline or API is down, we log it but don't crash.
// The UI can still use whatever is currently in the DB.
logger.Error("Proclamation sync failed, skipping update: %v", err)
return nil, err
}
logger.Debug("Request Granted")
for i := range proclamations {
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
}
var storedProclamationSettings []db.Proclamation
database.Select("ID", "Selected").Find(&storedProclamationSettings)
// [ID]Selected
stash := make(map[string]bool)
for _, row := range storedProclamationSettings {
stash[row.ID] = row.Selected
}
for i := range proclamations {
proclamations[i].Selected = stash[proclamations[i].ID]
}
err = database.Transaction(func(tx *gorm.DB) error {
ids := make([]string, len(proclamations))
for i, p := range proclamations {
ids[i] = p.ID
}
if len(ids) == 0 {
// API returned nothing → DB should be empty
if err := tx.Where("1 = 1").Delete(&db.Proclamation{}).Error; err != nil {
return err
}
} else {
// Remove anything not in API
if err := tx.Where("id NOT IN ?", ids).Delete(&db.Proclamation{}).Error; err != nil {
return err
}
}
// Upsert (preserve Selected automatically)
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"content",
"solfeggio_frequency_id",
"category_id",
"available",
"price",
}),
}).Create(&proclamations).Error; err != nil {
return err
}
// Return nil as err for this statement, does not exit the function
return nil
})
logger.Debug("Proclamations stored to database")
if err != nil {
logger.Error("Failed to save synced proclamations: %v", err)
return nil, err
}
logger.Info("Successfully synced proclamations: %d", len(proclamations))
return proclamations, nil
}
func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) {
fullURL := "https://portal.radiantwavetech.com/api/v1/categories"
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
if err != nil {
logger.Error("Category sync failed, skipping update: %v", err)
return nil, err
}
err = database.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil {
return err
}
if err := tx.Save(&categories).Error; err != nil {
return err
}
return nil
})
if err != nil {
logger.Error("failed to sync proclamation categories: %v", err)
return nil, err
}
logger.Info("Successfully synced proclamation categories: %d", len(categories))
return categories, nil
}
func SyncFrequencies(database *gorm.DB) ([]db.SolfeggioFrequency, error) {
fullURL := "https://portal.radiantwavetech.com/api/v1/frequencies"
frequencies, err := netreq.GetJSON[db.SolfeggioFrequency](fullURL)
if err != nil {
logger.Error("Frequency sync failed, skipping update: %v", err)
return nil, err
}
err = database.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&db.SolfeggioFrequency{}).Error; err != nil {
return err
}
if err := tx.Save(&frequencies).Error; err != nil {
return err
}
return nil
})
if err != nil {
logger.Error("failed to sync solfeggio frequencies: %v", err)
return nil, err
}
logger.Info("Succesfully synced solfeggio frequencies: %d", len(frequencies))
return frequencies, nil
}