diff --git a/.vscode/launch.json b/.vscode/launch.json
index ed41e33..1226d3e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -11,12 +11,5 @@
"mode": "debug",
"program": "rhemawave.go"
},
- {
- "name": "Launch Package",
- "type": "go",
- "request": "launch",
- "mode": "auto",
- "program": "${fileDirname}"
- }
]
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 13ae62a..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Rhema Wave Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## IMPORTANT!
-`9cdef97` - This commit hash is considered the "Great Reset Point".
-Many bugs, issues, and complexities have been discovered, leading to a restructure
-of the project, packaging, deployment and even target machine environment.
-All tags prior to this commit have been deleted. All releases removed, all archived
-packages dropped from the server, and even the server structure has been changed.
-v0.9.0 will represent the "beta" release from this point forward. Until full release, we will
-remain at v0.9.x.
-
-## [Unreleased]
-
-### Added
-- TOS screens now display real content from the RWT Welcome Screen document (10 sections)
-- TOS content is stored in the database and seeded from HTML files on disk, making it easy to update without recompiling
-- WiFi setup page now appears automatically before TOS screens if no internet connection is detected
-- Dev script `setup.sh` for syncing assets to /usr/ on development machines
-
----
-
-## [v0.9.22]
-
-### Added
-- Batched texture rendering path (BeginTextureRendering/RenderTextureBatched/EndTextureRendering) for ScrollerPage
-- Ring buffer (LineDeque) for ScrollerPage line management — O(1) prepend/append/remove
-- In-memory config value cache in db package — eliminates repeated SQLite queries
-- Buffered channel-based logger — single writer goroutine replaces per-log goroutine spawning
-- Cached uniform locations on Shader struct — looked up once at load, reused every frame
-
-### Changed
-- FontManager now reuses a persistent FBO, VAO, and VBO for string texture rendering instead of creating/deleting per call
-- RenderSolidColorQuad uses cached uniform locations and removes per-frame glGetError calls
-- Welcome page caches init_font_size at Init instead of querying the database every frame
-- ScrollerPage.Destroy uses gl.Flush instead of gl.Finish to avoid blocking CPU on page transitions
-- Scroll offset calculation uses simple subtraction instead of math.Mod with float64 round-trip
-- Application cleanup now properly shuts down the logger goroutine via logger.Close()
-
-### Removed
-- Removed per-frame CheckGLError calls from RenderSolidColorQuad (caused GPU pipeline stalls)
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 549b4d4..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Project Overview
-
-RhemaWave is a Go-based fullscreen SDL2/OpenGL application designed to run on Linux kiosk systems. It displays proclamations with audio playback and animated visual patterns. The application uses a page-based navigation system and includes network connectivity validation, license key management, and an auto-update mechanism.
-
-## Build & Development
-
-### Development Workflow
-
-```bash
-nix develop # enter dev shell (provides Go, SDL2, etc.)
-go build -o bin/rhemawave rhemawave.go # build binary
-./bin/rhemawave # run (requires display server + audio)
-./dev_scripts/reset.sh # wipe local database and start fresh
-```
-
-The binary at `bin/rhemawave` resolves assets via `../share/rhemawave` relative to itself, so the repo layout (`bin/` + `share/rhemawave/`) works out of the box.
-
-### Nix Build
-
-```bash
-nix build # produces ./result/bin/rhemawave + ./result/share/rhemawave/
-```
-
-The flake's `postInstall` copies `share/` into `$out/`, preserving the same relative layout.
-
-### Testing
-There are currently no automated tests in this codebase.
-
-## Architecture
-
-### Application Entry Point
-- `rhemawave.go` is the main entry point, calls `internal/application.Run()`
-- `internal/application/application.go` contains the core application lifecycle
-
-### Core Application Flow
-1. **Initialization**
- - Create application data directory (`~/.local/share/rhemawave`)
- - Initialize SQLite database (`data.db`) with GORM
- - Initialize SDL2 subsystems (VIDEO, AUDIO)
- - Initialize SDL_ttf for font rendering
- - Create fullscreen OpenGL 3.3 Core Profile window
- - Initialize shader and font managers
- - Hide cursor and enable relative mouse mode
-
-2. **Validation Phase** (startup page stack, top → bottom)
- - WiFi setup page (if no internet connection detected) — shown first
- - TOS sections from database (unaccepted only, ordered by section number)
- - Email address page (if missing)
- - License key page (if missing)
- - Welcome page — always at bottom
- - License key is also validated against the API
-
-3. **Event Loop** (60 FPS target)
- - Poll SDL events
- - Handle keyboard events via keybinds system
- - Update current page with delta time
- - Render current page
- - Apply pending page transitions
-
-### Page System
-Pages implement the `page.Page` interface:
-- `Init(app ApplicationInterface) error` - Setup resources
-- `HandleEvent(event *sdl.Event) error` - Handle input
-- `Update(dt float32) error` - Update state
-- `Render() error` - Draw to screen
-- `Destroy() error` - Cleanup resources
-
-Pages can embed `page.Base` for common functionality (OpenGL quad rendering, projection matrix, screen dimensions).
-
-**Page Navigation:**
-- `PushPage(p)` - Add page to stack (creates history)
-- `SwitchPage(p)` - Replace current page
-- `UnwindToPage(p)` - Pop back to a specific page (or create if not in stack)
-
-**Available Pages:**
-- `Welcome` - Initial screen showing version
-- `ScrollerPage` - Main proclamation player (F2)
-- `Settings` - Settings menu (F3)
-- `WiFiSetupPage` - Network configuration
-- `LicenseKeyPage` - License key entry
-- `EmailAddressPage` - Email address entry
-- `AudioDevices` - Audio device selection
-- `ProclamationOptions` - Proclamation selection
-- `TOSPage` - Terms of Service acceptance screen (scrollable, Y/N to accept/decline)
-
-### Key Global Keybinds
-Defined in `application.initKeybinds()`:
-- `F1` - Navigate to Welcome page
-- `F2` - Navigate to Player (ScrollerPage)
-- `F3` - Navigate to Settings
-- `SHIFT+UP` - Increase volume
-- `SHIFT+DOWN` - Decrease volume
-- `RIGHT_CTRL+Q` - Quit application
-
-### Package Structure
-
-**`internal/application`** - Core application lifecycle, event loop, page management, validation
-
-**`internal/page`** - Page interface and all page implementations
-
-**`internal/db`** - Database layer using GORM with SQLite
-- Models: `Config`, `Proclamations`, `Log`, `TOSSection`
-- Manages configuration key-value pairs
-- Seeds default proclamations from `share/rhemawave/proclamations/`
-- Seeds TOS sections from `share/rhemawave/tos/*.html` (filename format: `{NN} - {Title}.html`)
-
-**`internal/audio`** - SDL2 audio subsystem integration (currently minimal wrapper)
-
-**`internal/mixer`** - Audio playback manager for music and proclamations
-
-**`internal/graphics`** - OpenGL utilities and display orientation handling
-
-**`internal/shaderManager`** - Loads and manages OpenGL shaders from `share/rhemawave/shaders/`
-
-**`internal/fontManager`** - Loads and manages TrueType fonts from `share/rhemawave/fonts/`
-
-**`internal/keybinds`** - Global keyboard shortcut registration and dispatch
-
-**`internal/network`** - Network connectivity manager (uses D-Bus for NetworkManager on Linux)
-
-**`internal/logger`** - Logging system that writes to database and stdout
-
-**`internal/colors`** - Color utilities
-
-**`internal/pattern`** - Visual pattern generation (line patterns, color patterns)
-
-### Repository Layout
-
-```
-bin/ # build output (gitignored)
- rhemawave
-share/rhemawave/ # assets (checked in)
- audio/ # Audio files (audio.wav, etc.)
- fonts/ # TrueType fonts
- shaders/ # GLSL shader files
- proclamations/ # Proclamation text files
- home/ # Non-commercial proclamations
- commercial/ # Commercial proclamations
- tos/ # TOS HTML files (seeded into DB on first run)
-packaging/ # deployment configs (kiosk, updater, polkit)
- kiosk/ # kiosk system configs
- rhemawave-updater.py # auto-updater script
-internal/ # Go source packages
-dev_scripts/
- reset.sh # deletes ~/.local/share/rhemawave/
-flake.nix # Nix build and dev shell
-```
-
-### Important Implementation Details
-
-**Asset Resolution:** `resolveAssetsDir()` computes `filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")` — works in both dev (`bin/rhemawave` → `share/rhemawave/`) and Nix install (`$out/bin/rhemawave` → `$out/share/rhemawave/`).
-
-**OpenGL Context:** Uses OpenGL 3.3 Core Profile with VSync enabled. The page base class sets up a reusable quad VAO/VBO for texture and solid color rendering.
-
-**Database Location:** `~/.local/share/rhemawave/data.db` - Contains configuration, proclamations, and log entries
-
-**Fullscreen Mode:** Uses `SDL_WINDOW_FULLSCREEN_DESKTOP` to match native display resolution
-
-**Display Orientation:** Handled by `graphics.SetDisplayOrientation()` which swaps width/height for portrait displays
-
-**Version Injection:** Git version is injected at build time via ldflags: `-X 'rhemawavetech.com/rhemawave/internal/page.GitVersion=${VERSION}'`
-
-**Network Manager:** Uses D-Bus to communicate with NetworkManager for WiFi configuration and connectivity status
-
-## Development Workflow
-
-### Adding a New Page
-1. Create `internal/page/.go`
-2. Implement the `page.Page` interface
-3. Embed `page.Base` for common functionality
-4. Register keybind in `application.initKeybinds()` if needed
-5. Add navigation logic from existing pages
-
-### Modifying Database Schema
-1. Update models in `internal/db/db.go`
-2. GORM AutoMigrate will handle schema changes on next run
-3. Add seed data logic if needed in `ensureDefaultsExist()`, `seedDefaultProclamations()`, or `seedDefaultTOSSections()`
-
-### Adding New Assets
-1. Place files in appropriate `share/rhemawave/` subdirectory
-2. They will be included automatically in Nix builds via `postInstall`
-3. Access via config values (`assets_dir`) or direct paths
-
-### Working with Shaders
-1. Create `.vert` and `.frag` files in `share/rhemawave/shaders/`
-2. Load via `shaderManager.GetShader("")`
-3. Shader must have uniforms: `u_mvpMatrix`, `u_color` or `u_textColor`, `u_texture` (if using textures)
-
-## Important Constraints
-
-- Must build with `CGO_ENABLED=1` for SDL2 bindings
-- Target platform is Linux (uses D-Bus for NetworkManager)
-- Application expects to run as kiosk user with polkit rules for system operations
-- Assets are resolved relative to the binary (`../share/rhemawave`), so `bin/` and `share/` must be siblings
-- Version string comes from git tags (release) or commit hash (dev)
-- ShaderManager, FontManager, and Logger use `sync.Once` singletons — they must be initialized (via their `Init()` or `Initialize()` functions) before calling `Get()`; calling `Get()` before initialization panics
-- Deployed via NixOS-Anywhere to kiosk hardware
diff --git a/README.md b/README.md
index e337b3e..e5d0bed 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,13 @@ Stored in `~/.local/share/rhemawave/`:
- `log.txt` — application log
- `license_key` — license key file
+### Development Rules
+These rules must be followed during the development of RhemaWave to retain a
+maintainable application.
+1. The Application package is soley responsible for managing the database handler, and the
+configuration cache. Internal packages may update the configuration within the database, and
+then signal the application to refresh the cache via a callback function `refreshCache()`.
+
## Deployment
Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs.
diff --git a/internal/application/application.go b/internal/application/application.go
index edb6023..13885ca 100644
--- a/internal/application/application.go
+++ b/internal/application/application.go
@@ -1,9 +1,7 @@
package application
import (
- "fmt"
- "io"
- "net/http"
+ "errors"
"os"
"path/filepath"
"sync/atomic"
@@ -12,6 +10,7 @@ import (
"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/fontManager"
"rhemawavetech.com/rhemawave/internal/graphics"
@@ -19,7 +18,6 @@ import (
"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"
)
@@ -40,6 +38,9 @@ type Application struct {
pageStack []page.Page
pendingAction func()
running bool
+ DB *gorm.DB
+ Config atomic.Pointer[db.AppConfig]
+ PageRouter *page.Router
}
// ----------------------------------------------------------------------------
@@ -47,174 +48,111 @@ type Application struct {
// ----------------------------------------------------------------------------
func Run() {
- logger.Info("Starting application")
+ var err error
+ var homeDir string
+ var dataDir string
+ var DB *gorm.DB
+ var initialConfig db.AppConfig
+ var result *gorm.DB
- appDataDir, err := initializeApplicationDirectory()
+ logger.Info("Starting Application")
+ logger.Info("Finding $HOME")
+
+ homeDir, err = os.UserHomeDir()
if err != nil {
- logger.Fatal("Failed to initialize application directory: %v", err)
+ logger.Error("unable to fetch user $HOME directory: %v", err)
+ return
}
- logger.Info("App directory: %s", appDataDir)
- logger.AddFile(filepath.Join(appDataDir, "log.txt"))
-
- if err := initializeDatabase(appDataDir); err != nil {
- logger.Fatal("Database initialization failed: %v", err)
+ logger.Info("Constructing DataDir Path")
+ 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
}
- app := &Application{}
-
- app.initializeSDLAndOpenGL()
- defer app.cleanup()
-
- app.initializeManagers()
- app.initializeAudio()
- app.validateLicenseKey()
- app.startupPageStack()
- app.runEventLoop()
-}
-
-// ----------------------------------------------------------------------------
-// Initialization Functions
-// ----------------------------------------------------------------------------
-
-func initializeApplicationDirectory() (string, error) {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("failed to get user home directory: %w", err)
- }
-
- appDataDir := filepath.Join(homeDir, ".local", "share", "rhemawave")
-
- if err := os.MkdirAll(appDataDir, 0755); err != nil {
- return "", fmt.Errorf("could not create application data directory %s: %w", appDataDir, err)
- }
-
- testFile := filepath.Join(appDataDir, ".access_test")
+ testFile := filepath.Join(dataDir, ".access_test")
f, err := os.Create(testFile)
if err != nil {
- return "", fmt.Errorf("application data directory %s is not writable: %w", appDataDir, err)
+ logger.Error("data %s is not writeable: %v", dataDir, err)
+ return
}
f.Close()
os.Remove(testFile)
- return appDataDir, nil
-}
+ logger.AddFile(filepath.Join(dataDir, "log.txt"))
-func initializeDatabase(appDataDir string) error {
- dbPath := filepath.Join(appDataDir, "data.db")
- if err := db.InitDatabase(dbPath); err != nil {
- return fmt.Errorf("failed to initialize database: %w", err)
- }
- logger.AddDatabase(db.DB)
- return nil
-}
+ logger.Info("Connecting to database")
-type progressWriter struct {
- w io.Writer
- written *atomic.Int64
-}
-
-func (pw *progressWriter) Write(p []byte) (int, error) {
- n, err := pw.w.Write(p)
- pw.written.Add(int64(n))
- return n, err
-}
-
-// ensureAudioFile checks whether the selected audio file exists. If it does,
-// it returns nil. Otherwise it starts a background download and returns a
-// DownloadProgressPage that tracks the download so it can be pushed onto the
-// page stack.
-func ensureAudioFile() *page.DownloadProgressPage {
- assetsDir, err := db.GetConfigValue("assets_dir")
+ DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
if err != nil {
- logger.Error("Failed to get assets_dir: %v", err)
- return nil
- }
- selectedAudio, err := db.GetConfigValue("selected_audio")
- if err != nil || selectedAudio == "" {
- logger.Warning("No selected_audio configured")
- return nil
+ logger.Error("application failed to connect to the database: %v", err)
+ return
}
- audioPath := filepath.Join(assetsDir, selectedAudio)
- if _, err := os.Stat(audioPath); err == nil {
- logger.Info("Audio file found: %s", audioPath)
- return nil
+ if err = db.Migrate(DB); err != nil {
+ logger.Error("application failed to perform database migrations: %v", err)
+ return
}
- logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL)
-
- totalBytes := &atomic.Int64{}
- bytesWritten := &atomic.Int64{}
- done := &atomic.Bool{}
- hasError := &atomic.Bool{}
-
- downloadPage := &page.DownloadProgressPage{
- TotalBytes: totalBytes,
- BytesWritten: bytesWritten,
- Done: done,
- HasError: hasError,
+ // Populate the initial Config cache OR create defaults
+ result = DB.First(&initialConfig)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ logger.Info("First run detected: Creating default configuration...")
+ initialConfig = db.AppConfig{
+ DataDir: dataDir,
+ AssetsDir: dataDir,
+ SystemLevel: "BASIC",
+ SelectedAudioDevice: "default",
+ InitialFontSize: 128,
+ StandardFontSize: 32,
+ LinePattern: "fibonacci",
+ LastVolume: 128,
+ AudioEnabled: true,
+ }
+ if err := DB.Create(&initialConfig).Error; err != nil {
+ logger.Error("failed to insert default config into database: %v", err)
+ return
+ }
+ } else {
+ logger.Error("error querying database for initial config: %v", result.Error)
+ return
+ }
}
- go func() {
- defer done.Store(true)
+ logger.AddDatabase(DB)
- if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil {
- logger.Error("Failed to create audio directory: %v", err)
- hasError.Store(true)
- return
- }
+ app := &Application{
+ DB: DB,
+ }
- resp, err := http.Get(audioFileURL)
- if err != nil {
- logger.Error("Failed to download audio file: %v", err)
- hasError.Store(true)
- return
- }
- defer resp.Body.Close()
+ app.refreshCache()
- if resp.StatusCode != http.StatusOK {
- logger.Error("Audio download returned status %d", resp.StatusCode)
- hasError.Store(true)
- return
- }
+ w, h := app.initializeSDLAndOpenGL()
+ defer app.cleanup()
- if resp.ContentLength > 0 {
- totalBytes.Store(resp.ContentLength)
- }
-
- tmp := audioPath + ".tmp"
- f, err := os.Create(tmp)
- if err != nil {
- logger.Error("Failed to create audio file: %v", err)
- hasError.Store(true)
- return
- }
-
- pw := &progressWriter{w: f, written: bytesWritten}
- _, err = io.Copy(pw, resp.Body)
- f.Close()
- if err != nil {
- os.Remove(tmp)
- logger.Error("Failed to write audio file: %v", err)
- hasError.Store(true)
- return
- }
-
- if err := os.Rename(tmp, audioPath); err != nil {
- os.Remove(tmp)
- logger.Error("Failed to move audio file into place: %v", err)
- hasError.Store(true)
- return
- }
-
- logger.Info("Audio file downloaded successfully (%d bytes)", bytesWritten.Load())
- }()
-
- return downloadPage
+ app.initializeManagers()
+ app.initializeAudio()
+ app.PageRouter = page.NewRouter(page.PageCtx{
+ CfgPtr: app.Config.Load(),
+ DbPtr: app.DB,
+ W: w,
+ H: h,
+ RefreshConfig: app.refreshCache,
+ })
+ app.runEventLoop()
}
-func (app *Application) initializeSDLAndOpenGL() {
+func (app *Application) refreshCache() {
+ var freshConfig db.AppConfig
+ if result := app.DB.First(&freshConfig); result.Error != nil {
+ logger.Error("failed to fetch configuration from the database: %v", result.Error)
+ }
+ app.Config.Store(&freshConfig)
+}
+
+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)
@@ -283,42 +221,40 @@ func (app *Application) initializeSDLAndOpenGL() {
app.initKeybinds()
logger.Info("SDL and OpenGL initialization complete")
+ return w, h
}
func (app *Application) initializeManagers() {
logger.Info("Initializing ShaderManager")
- if err := shaderManager.InitShaderManager(); err != nil {
+ shaderDir := filepath.Join(app.Config.Load().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(); err != nil {
+ if err := fontManager.InitFontManager(app.Config.Load()); err != nil {
logger.Fatal("FontManager initialization failed: %v", err)
}
}
func (app *Application) initializeAudio() {
logger.Info("Initializing audio")
- audioDevice, err := db.GetConfigValue("audio_device_name")
- if err != nil {
- logger.Warning("Failed to get audio device name from config: %v", err)
- }
- if err := mixer.Init(audioDevice); err != nil {
- logger.Error("Unable to initialize audio device '%s': %v", audioDevice, err)
+ if err := mixer.Init(app.Config.Load(), app.refreshCache); err != nil {
+ logger.Error("Unable to initialize audio device '%s': %v", app.Config.Load().SelectedAudioDevice, err)
logger.Error("Audio will be unavailable until a device is configured")
} else {
- logger.Info("Audio initialized with device: '%s'", audioDevice)
+ logger.Info("Audio initialized with device: '%s'", app.Config.Load().SelectedAudioDevice)
}
}
func (app *Application) cleanup() {
logger.Info("Cleaning up")
- for _, p := range app.pageStack {
- p.Destroy()
+ if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
+ app.PageRouter.ActivePage.Destroy()
}
- app.pageStack = nil
graphics.DestroyVirtualFramebuffer()
@@ -337,8 +273,8 @@ func (app *Application) cleanup() {
// ----------------------------------------------------------------------------
func (app *Application) runEventLoop() {
- if len(app.pageStack) == 0 {
- logger.Fatal("No initial page loaded, pageStack is empty")
+ if app.PageRouter == nil || app.PageRouter.ActivePage == nil {
+ logger.Fatal("No initial page loaded, Router is empty")
}
const frameDelay = time.Second / targetFPS
@@ -360,7 +296,6 @@ func (app *Application) runEventLoop() {
app.updateCurrentPage(deltaTime)
app.renderFrame()
- app.applyPendingAction()
frameTime := time.Since(frameStart)
if frameTime < frameDelay {
@@ -388,7 +323,7 @@ func (app *Application) handleEvents() {
}
}
- if currentPage := app.currentPage(); currentPage != nil {
+ if currentPage := app.PageRouter.ActivePage; currentPage != nil {
if err := currentPage.HandleEvent(&event); err != nil {
logger.Error("Page event handler error: %v", err)
}
@@ -397,7 +332,7 @@ func (app *Application) handleEvents() {
}
func (app *Application) updateCurrentPage(deltaTime float32) {
- if currentPage := app.currentPage(); currentPage != nil {
+ if currentPage := app.PageRouter.ActivePage; currentPage != nil {
currentPage.Update(deltaTime)
}
}
@@ -407,7 +342,7 @@ func (app *Application) renderFrame() {
gl.ClearColor(0.0, 0.0, 0.0, 1.0)
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
- if currentPage := app.currentPage(); currentPage != nil {
+ if currentPage := app.PageRouter.ActivePage; currentPage != nil {
currentPage.Render()
}
@@ -421,21 +356,16 @@ func (app *Application) renderFrame() {
func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
- app.PushPage(&page.Settings{})
+ app.PageRouter.NavigateTo(page.SETTINGS)
})
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
- downloadPage := ensureAudioFile()
- if downloadPage != nil {
- downloadPage.NextPage = &page.ScrollerPage{}
- app.PushPage(downloadPage)
- } else {
- app.PushPage(&page.ScrollerPage{})
- }
+ app.PageRouter.NavigateTo(page.SCROLLER)
})
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
- app.UnwindToPage(&page.Welcome{})
+ app.PageRouter.NavigateTo(page.HOME)
+
})
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
@@ -443,11 +373,11 @@ func (app *Application) initKeybinds() {
})
keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() {
- mixer.SetVolume128(volumeStepSize)
+ mixer.SetVolume128(volumeStepSize, app.Config.Load())
})
keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() {
- mixer.SetVolume128(-volumeStepSize)
+ mixer.SetVolume128(-volumeStepSize, app.Config.Load())
})
}
@@ -455,239 +385,6 @@ func (app *Application) Stop() {
app.running = false
}
-// ----------------------------------------------------------------------------
-// Page Management
-// ----------------------------------------------------------------------------
-
-func (app *Application) currentPage() page.Page {
- if len(app.pageStack) == 0 {
- return nil
- }
- return app.pageStack[len(app.pageStack)-1]
-}
-
-func (app *Application) applyPendingAction() {
- if app.pendingAction != nil {
- app.pendingAction()
- app.pendingAction = nil
- }
-}
-
-func (app *Application) schedulePush(p page.Page) {
- app.pendingAction = func() {
- if err := p.Init(app); err != nil {
- logger.Error("Failed to init page on push: %v", err)
- return
- }
-
- if topPage := app.currentPage(); topPage != nil {
- topPage.Destroy()
- }
-
- app.pageStack = append(app.pageStack, p)
- }
-}
-
-func (app *Application) scheduleSwitch(p page.Page) {
- app.pendingAction = func() {
- if topPage := app.currentPage(); topPage != nil {
- topPage.Destroy()
- app.pageStack = app.pageStack[:len(app.pageStack)-1]
- }
-
- if err := p.Init(app); err != nil {
- logger.Error("Failed to init page on switch: %v", err)
- return
- }
-
- app.pageStack = append(app.pageStack, p)
- }
-}
-
-func (app *Application) scheduleUnwind(targetPage page.Page) {
- app.pendingAction = func() {
- foundIndex := -1
- for i := len(app.pageStack) - 1; i >= 0; i-- {
- if app.pageStack[i] == targetPage {
- foundIndex = i
- break
- }
- }
-
- if foundIndex != -1 {
- for i := len(app.pageStack) - 1; i > foundIndex; i-- {
- app.pageStack[i].Destroy()
- }
- app.pageStack = app.pageStack[:foundIndex+1]
- } else {
- if topPage := app.currentPage(); topPage != nil {
- topPage.Destroy()
- app.pageStack = app.pageStack[:len(app.pageStack)-1]
- }
-
- if err := targetPage.Init(app); err != nil {
- logger.Error("Failed to init page on unwind: %v", err)
- return
- }
-
- app.pageStack = append(app.pageStack, targetPage)
- }
- }
-}
-
-func (app *Application) SchedulePop() {
- app.pendingAction = func() {
- if len(app.pageStack) == 0 {
- return
- }
-
- topPage := app.pageStack[len(app.pageStack)-1]
- topPage.Destroy()
- app.pageStack = app.pageStack[:len(app.pageStack)-1]
-
- if newTop := app.currentPage(); newTop != nil {
- if err := newTop.Init(app); err != nil {
- logger.Error("Failed to re-init page after pop: %v", err)
- }
- }
- }
-}
-
-// ----------------------------------------------------------------------------
-// Public Page Navigation Interface
-// ----------------------------------------------------------------------------
-
-func (app *Application) PushPage(p page.Page) {
- app.schedulePush(p)
-}
-
-func (app *Application) SwitchPage(p page.Page) {
- app.scheduleSwitch(p)
-}
-
-func (app *Application) UnwindToPage(p page.Page) {
- app.scheduleUnwind(p)
-}
-
-func (app *Application) GetDrawableSize() (w, h int32) {
- return graphics.VirtualWidth, graphics.VirtualHeight
-}
-
-func (app *Application) PopPage() {
- app.SchedulePop()
-}
-
-// ----------------------------------------------------------------------------
-// License Validation
-// ----------------------------------------------------------------------------
-
-func (app *Application) validateLicenseKey() {
- logger.Info("Validating license key")
- validator := license.NewValidator(apiURL, apiPath)
-
- key, err := db.GetConfigValue("license_key")
- if err != nil {
- logger.Error("Failed to fetch license key from database: %v", err)
- }
-
- email, err := db.GetConfigValue("email_address")
- if err != nil {
- logger.Error("Failed to fetch email from database: %v", err)
- }
-
- result, err := validator.Validate(key, email)
- if err != nil {
- logger.Error("License validation failed: %v", err)
- app.License = nil
- return
- }
-
- logger.Info("License validation successful")
- app.License = result
-}
-
-// ----------------------------------------------------------------------------
-// Startup Page Stack
-// ----------------------------------------------------------------------------
-
-func (app *Application) startupPageStack() {
- app.PushPage(&page.Welcome{})
- app.applyPendingAction()
-
- key, _ := db.GetConfigValue("license_key")
- if key == "" {
- logger.Info("No license key found, pushing license key page")
- app.PushPage(&page.LicenseKeyPage{})
- app.applyPendingAction()
- } else {
- // Ensure the license key file exists for the updater script.
- // Older versions only stored the key in the database.
- if homeDir, err := os.UserHomeDir(); err == nil {
- keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
- if _, err := os.Stat(keyPath); os.IsNotExist(err) {
- logger.Info("License key file missing, writing from database")
- if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
- logger.Error("Failed to create license key directory: %v", err)
- } else if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil {
- logger.Error("Failed to write license key file: %v", err)
- }
- }
- }
- }
-
- email, _ := db.GetConfigValue("email_address")
- if email == "" {
- logger.Info("No email address found, pushing email address page")
- app.PushPage(&page.EmailAddressPage{})
- app.applyPendingAction()
- }
-
- // TOS pages — must be accepted before anything else.
- // Pushed last so they sit on top of the stack (shown first).
- sections, err := db.GetTOSSections()
- if err != nil {
- logger.Error("Failed to load TOS sections: %v", err)
- }
- // Push in reverse order so section 1 is on top of the stack
- for i := len(sections) - 1; i >= 0; i-- {
- s := sections[i]
- configKey := fmt.Sprintf("tos_section_%d_accepted", s.SectionNumber)
- accepted, _ := db.GetConfigValue(configKey)
- if accepted != "true" {
- app.PushPage(&page.TOSPage{
- Title: s.Title,
- Content: s.Content,
- ConfigKey: configKey,
- })
- app.applyPendingAction()
- }
- }
-
- // Internet check — pushed last so it appears first if no internet.
- // Skip WiFi setup if ethernet is connected (it auto-configures) or if
- // internet is already reachable through any interface.
- nm, err := network.New()
- if err != nil {
- logger.Warning("Could not initialize network manager: %v", err)
- } else {
- status, err := nm.CheckInternetConnection()
- if err != nil {
- logger.Warning("Network connectivity check failed: %v", err)
- } else {
- switch status.StatusCode {
- case network.StatusEthernetConnectedInternetUp,
- network.StatusEthernetConnectedNoInternet,
- network.StatusWifiConnectedInternetUp:
- // Internet is up or ethernet is connected and will auto-configure
- default:
- logger.Info("No internet connection, pushing WiFi setup page")
- app.PushPage(&page.WiFiSetupPage{})
- app.applyPendingAction()
- }
- }
- }
-}
-
func (app *Application) Quit() {
logger.Info("User has quit the application")
app.running = false
diff --git a/internal/db/db.go b/internal/db/db.go
deleted file mode 100644
index c88380c..0000000
--- a/internal/db/db.go
+++ /dev/null
@@ -1,492 +0,0 @@
-package db
-
-import (
- "fmt"
- "log"
- "os"
- "path/filepath"
- "strings"
- "sync"
-
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
- "rhemawavetech.com/rhemawave/internal/logger"
-)
-
-var DB *gorm.DB
-
-// configCache is an in-memory cache of config values to avoid repeated DB queries.
-var configCache = struct {
- sync.RWMutex
- m map[string]string
-}{m: make(map[string]string)}
-
-type Config struct {
- gorm.Model
- Key string `gorm:"uniqueIndex"`
- Value string
-}
-
-type Proclamations struct {
- gorm.Model
- Title string
- Content string
- Commercial bool
- Selected bool
- Favorite bool
- Available bool
-}
-
-type Log struct {
- gorm.Model
- Timestamp string
- Level string
- Message string
-}
-
-func (Log) TableName() string {
- return "log_entries"
-}
-
-type TOSSection struct {
- gorm.Model
- SectionNumber int `gorm:"uniqueIndex"`
- Title string
- Content string // raw HTML
-}
-
-var defaultConfigValues = map[string]string{
- "home_dir": "",
- "assets_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave
- "log_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave/logs.log
- "email_address": "",
- "license_key": "",
- "system_type": "home",
- "system_capabilities": "",
- "selected_audio": "audio/audio.wav",
- "audio_device_name": "",
- "application_font": "Roboto-Regular",
- "init_font_size": "128",
- "standard_font_size": "32",
- "line_pattern": "fibonacci",
- "last_volume": "128",
- "audio_enabled": "true",
- "tos_section_1_accepted": "false",
- "tos_section_2_accepted": "false",
- "tos_section_3_accepted": "false",
- "tos_section_4_accepted": "false",
- "tos_section_5_accepted": "false",
- "tos_section_6_accepted": "false",
- "tos_section_7_accepted": "false",
- "tos_section_8_accepted": "false",
- "tos_section_9_accepted": "false",
- "tos_section_10_accepted": "false",
-}
-
-// InitDatabase initializes the SQLite database connection
-// and performs auto-migration for all models
-func InitDatabase(dbpath string) error {
- // Create the directory for the database if it doesn't exist
- dbDir := filepath.Dir(dbpath)
- if err := os.MkdirAll(dbDir, 0700); err != nil {
- log.Printf("Error creating database directory %s: %v", dbDir, err)
- return err
- }
-
- // Check if database file exists
- _, err := os.Stat(dbpath)
- dbExists := !os.IsNotExist(err)
-
- if !dbExists {
- log.Printf("Database file does not exist at %s, creating new database", dbpath)
- }
-
- dsn := fmt.Sprintf("file:%s", dbpath)
-
- DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
- if err != nil {
- log.Printf("Error opening database: %v", err)
- return err
- }
-
- // Get underlying sql.DB to set pragmas
- sqlDB, err := DB.DB()
- if err != nil {
- logger.Error("Error getting underlying database connection: %v", err)
- return err
- }
-
- // Configure SQLite for reliability and performance
- pragmas := []string{
- "PRAGMA journal_mode=WAL", // Write-Ahead Logging for better concurrency
- "PRAGMA synchronous=FULL", // Maximum durability
- "PRAGMA foreign_keys=ON", // Enable foreign key constraints
- "PRAGMA busy_timeout=5000", // Wait up to 5 seconds for locks
- "PRAGMA temp_store=MEMORY", // Keep temp tables in memory
- }
-
- for _, pragma := range pragmas {
- if _, err := sqlDB.Exec(pragma); err != nil {
- logger.Error("Error setting pragma %s: %v", pragma, err)
- return fmt.Errorf("failed to set pragma: %w", err)
- }
- }
-
- if err := sqlDB.Ping(); err != nil {
- logger.Error("Error pinging database: %v", err)
- return fmt.Errorf("database connection failed: %w", err)
- }
-
- log.Println("Database connected successfully")
-
- // Auto-migrate models
- err = DB.AutoMigrate(&Config{}, &Proclamations{}, &Log{}, &TOSSection{})
- if err != nil {
- logger.Error("Error migrating database: %v", err)
- return err
- }
- log.Println("Database migrated successfully")
-
- // Seed defaults
- err = ensureDefaultsExist()
- if err != nil {
- logger.Error("Error seeding default configuration values: %v", err)
- return err
- }
- err = seedDefaultProclamations()
- if err != nil {
- logger.Error("Error seeding default proclamations: %v", err)
- return err
- }
-
- err = seedDefaultTOSSections()
- if err != nil {
- logger.Error("Error seeding default TOS sections: %v", err)
- return err
- }
-
- log.Println("Database initialized successfully")
- return nil
-}
-
-// seedDefaultProclamations reads proclamation text files from the assets directory
-// and populates the database on first run
-func seedDefaultProclamations() error {
- var count int64
- err := DB.Model(&Proclamations{}).Count(&count).Error
- if err != nil {
- logger.Error("Error counting proclamations: %v", err)
- return err
- }
-
- // Only seed if table is empty
- if count > 0 {
- return nil
- }
-
- // Get the assets directory from config
- assetsDir, err := GetConfigValue("assets_dir")
- if err != nil {
- logger.Error("Error getting assets_dir: %v", err)
- return err
- }
-
- proclamationsBaseDir := assetsDir + "/proclamations"
-
- // Process both home and commercial directories
- subdirs := []string{"home", "commercial"}
- totalSeeded := 0
-
- for _, subdir := range subdirs {
- proclamationsDir := proclamationsBaseDir + "/" + subdir
- isCommercial := (subdir == "commercial")
-
- // Read all .txt files from the subdirectory
- files, err := os.ReadDir(proclamationsDir)
- if err != nil {
- logger.Error("Error reading proclamations directory %s: %v", proclamationsDir, err)
- continue // Continue with other directories even if one fails
- }
-
- for _, file := range files {
- if file.IsDir() || !strings.HasSuffix(file.Name(), ".txt") {
- continue
- }
-
- filePath := proclamationsDir + "/" + file.Name()
- content, err := os.ReadFile(filePath)
- if err != nil {
- logger.Error("Error reading file %s: %v", filePath, err)
- continue
- }
-
- // Extract title from filename (remove .txt extension)
- title := strings.TrimSuffix(file.Name(), ".txt")
-
- // Select "Relaxation" as the default proclamation
- isSelected := (title == "Relaxation")
-
- proclamation := Proclamations{
- Title: title,
- Content: string(content),
- Commercial: isCommercial,
- Selected: isSelected,
- Favorite: false,
- Available: true,
- }
-
- if err := DB.Create(&proclamation).Error; err != nil {
- logger.Error("Error creating proclamation from %s: %v", file.Name(), err)
- return err
- }
-
- if isSelected {
- logger.Error("Set default proclamation: %s", title)
- }
-
- totalSeeded++
- }
- }
-
- logger.Error("Seeded %d default proclamations from %s", totalSeeded, proclamationsBaseDir)
- return nil
-}
-
-// GetConfigValue retrieves a configuration value by key.
-// Results are cached in memory to avoid repeated DB queries.
-func GetConfigValue(key string) (string, error) {
- configCache.RLock()
- if v, ok := configCache.m[key]; ok {
- configCache.RUnlock()
- return v, nil
- }
- configCache.RUnlock()
-
- var config Config
- result := DB.First(&config, "key = ?", key)
- if result.Error != nil {
- return "", result.Error
- }
-
- configCache.Lock()
- configCache.m[key] = config.Value
- configCache.Unlock()
-
- return config.Value, nil
-}
-
-// GetConfigValues retrieves all configuration key-value pairs
-// Returns a map of key-value pairs OR any error encountered
-// This is mainly for compatibility with legacy code, but can
-// be useful for bulkl config referrals
-func GetConfigValues() (map[string]string, error) {
- var configs []Config
- result := DB.Find(&configs)
- if result.Error != nil {
- return nil, result.Error
- }
- configMap := make(map[string]string)
- for _, config := range configs {
- configMap[config.Key] = config.Value
- }
- return configMap, nil
-}
-
-// SetConfigValue sets a configuration value by key
-// Returns any error encountered || nil
-func SetConfigValue(key, value string) error {
- // First try to find existing config
- var config Config
- result := DB.Where("key = ?", key).First(&config)
-
- if result.Error != nil {
- if result.Error == gorm.ErrRecordNotFound {
- // Record doesn't exist, create new one
- config = Config{Key: key, Value: value}
- if err := DB.Create(&config).Error; err != nil {
- return fmt.Errorf("failed to create config value for key %s: %w", key, err)
- }
- } else {
- // Some other error occurred
- return fmt.Errorf("failed to query config for key %s: %w", key, result.Error)
- }
- } else {
- // Record exists, update it
- config.Value = value
- if err := DB.Save(&config).Error; err != nil {
- return fmt.Errorf("failed to update config value for key %s: %w", key, err)
- }
- }
-
- // Update cache
- configCache.Lock()
- configCache.m[key] = value
- configCache.Unlock()
-
- return nil
-}
-
-// GetProclamations retrieves all proclamation entries from the database
-// Returns a slice of Proclamations OR any error encountered
-func GetProclamations() ([]Proclamations, error) {
- var proclamations []Proclamations
- result := DB.Find(&proclamations)
- if result.Error != nil {
- return nil, result.Error
- }
- return proclamations, nil
-}
-
-// seedDefaultTOSSections reads HTML files from the assets directory and populates the
-// TOS sections table on first run.
-func seedDefaultTOSSections() error {
- var count int64
- if err := DB.Model(&TOSSection{}).Count(&count).Error; err != nil {
- return err
- }
- if count > 0 {
- return nil
- }
-
- assetsDir, err := GetConfigValue("assets_dir")
- if err != nil {
- return fmt.Errorf("getting assets_dir: %w", err)
- }
-
- tosDir := filepath.Join(assetsDir, "tos")
- files, err := os.ReadDir(tosDir)
- if err != nil {
- return fmt.Errorf("reading tos directory %s: %w", tosDir, err)
- }
-
- seeded := 0
- for _, file := range files {
- if file.IsDir() || !strings.HasSuffix(file.Name(), ".html") {
- continue
- }
-
- // Parse filename: "01 - WELCOME.html" -> number=1, title="WELCOME"
- name := strings.TrimSuffix(file.Name(), ".html")
- parts := strings.SplitN(name, " - ", 2)
- if len(parts) != 2 {
- logger.Error("Skipping TOS file with unexpected name format: %s", file.Name())
- continue
- }
-
- var sectionNum int
- if _, err := fmt.Sscanf(parts[0], "%d", §ionNum); err != nil {
- logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
- continue
- }
- title := strings.TrimSpace(parts[1])
-
- content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
- if err != nil {
- logger.Error("Error reading TOS file %s: %v", file.Name(), err)
- continue
- }
-
- section := TOSSection{
- SectionNumber: sectionNum,
- Title: title,
- Content: string(content),
- }
- if err := DB.Create(§ion).Error; err != nil {
- return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
- }
- seeded++
- }
-
- logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
- return nil
-}
-
-// GetTOSSections returns all TOS sections ordered by section number.
-func GetTOSSections() ([]TOSSection, error) {
- var sections []TOSSection
- result := DB.Order("section_number asc").Find(§ions)
- if result.Error != nil {
- return nil, result.Error
- }
- return sections, nil
-}
-
-// GetLogs retrieves log entries from the database, ordered by creation time ascending.
-// If limit > 0, at most that many entries are returned.
-func GetLogs(limit int) ([]Log, error) {
- var logs []Log
- query := DB.Order("created_at DESC")
- if limit > 0 {
- query = query.Limit(limit)
- }
- return logs, query.Find(&logs).Error
-}
-
-// ensureDefaultsExist checks the current configs table rows and inserts any new rows
-// from defaultConfigValues to the database if they don't exist.
-// This is essentially an automatic migration for new config values.
-func ensureDefaultsExist() error {
- for key, value := range defaultConfigValues {
- var config Config
- result := DB.Where("key = ?", key).First(&config)
-
- // Always re-resolve path-based config values from the current binary location
- switch key {
- case "assets_dir":
- value = resolveAssetsDir()
- case "log_dir":
- value = filepath.Join(resolveAssetsDir(), "logs.log")
- }
-
- if result.Error == gorm.ErrRecordNotFound {
- // Key doesn't exist, create it with default value
- if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
- return fmt.Errorf("failed to create default config for %s: %w", key, err)
- }
- log.Printf("Added missing config key: %s", key)
- } else if result.Error != nil {
- return result.Error
- } else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
- // Update stale path-based values (e.g. after Nix store update)
- config.Value = value
- if err := DB.Save(&config).Error; err != nil {
- return fmt.Errorf("failed to update config for %s: %w", key, err)
- }
- log.Printf("Updated stale config key: %s", key)
- } else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
- // Migrate renamed audio file
- config.Value = value
- if err := DB.Save(&config).Error; err != nil {
- return fmt.Errorf("failed to migrate config for %s: %w", key, err)
- }
- log.Printf("Migrated config key: %s", key)
- }
- }
- return nil
-}
-
-func AudioEnabled() bool {
- on, err := GetConfigValue("audio_enabled")
- if err != nil {
- return false
- }
- return on == "true"
-}
-
-// resolveAssetsDir returns the assets directory. If RHEMAWAVE_ASSETS_DIR is
-// set, it is used directly (useful for debugging with dlv). Otherwise, it
-// resolves relative to the running binary: .../bin/rhemawave → .../share/rhemawave.
-// Falls back to /usr/local/share/rhemawave.
-func resolveAssetsDir() string {
- if dir := os.Getenv("RHEMAWAVE_ASSETS_DIR"); dir != "" {
- return dir
- }
- exe, err := os.Executable()
- if err == nil {
- exe, err = filepath.EvalSymlinks(exe)
- }
- if err == nil {
- return filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")
- }
- return filepath.Join("/usr", "local", "share", "rhemawave")
-}
diff --git a/internal/db/newDb.go b/internal/db/newDb.go
new file mode 100644
index 0000000..185ff06
--- /dev/null
+++ b/internal/db/newDb.go
@@ -0,0 +1,48 @@
+// 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 (
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+// Connect to the database and return the gorm.DB object.
+func Connect(dbpath string) (*gorm.DB, error) {
+ db, err := gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
+ if err != nil {
+ return nil, err
+ }
+ // Set pragmas
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, err
+ }
+ pragmas := []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA synchronous=FULL",
+ "PRAGMA foreign_keys=ON",
+ "PRAGMA busy_timeout=5000",
+ }
+
+ for _, pragma := range pragmas {
+ if _, err := sqlDB.Exec(pragma); err != nil {
+ return nil, err
+ }
+ }
+
+ return db, nil
+}
+
+// Migrate the database schema
+func Migrate(db *gorm.DB) error {
+ // Pass all models to be migrated to this function call.
+ if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/db/schema.go b/internal/db/schema.go
new file mode 100644
index 0000000..e10515d
--- /dev/null
+++ b/internal/db/schema.go
@@ -0,0 +1,39 @@
+// 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
+ SelectedAudio string // Audio filepath
+ 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"
+ LastVolume int // 0-128
+ AudioEnabled bool
+}
+
+type Log struct {
+ gorm.Model
+ Timestamp int64
+ Level string
+ Message string
+}
+
+type Proclamation struct {
+ gorm.Model
+ ID string `gorm:"primaryKey"`
+ Name string
+ Content string
+ SolfeggioFrequencyID int
+ CategoryID int
+ Selected bool
+ Available bool
+ Price float64
+}
diff --git a/internal/fontManager/fontManager.go b/internal/fontManager/fontManager.go
index 433ce3a..28fc510 100644
--- a/internal/fontManager/fontManager.go
+++ b/internal/fontManager/fontManager.go
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strconv"
"strings"
"sync"
@@ -35,7 +34,7 @@ var (
// InitFontManager initializes the FontManager singleton.
// It must be called once at startup after ShaderManager has been initialized.
-func InitFontManager() error {
+func InitFontManager(cfg *db.AppConfig) error {
fontManagerOnce.Do(func() {
// Dependency check: Ensure ShaderManager is ready
@@ -49,13 +48,8 @@ func InitFontManager() error {
Fonts: make(map[string]*FontEntry),
}
- assetsDir, err := db.GetConfigValue("assets_dir")
- if err != nil {
- initErr = fmt.Errorf("could not get assets_dir from config: %w", err)
- return
- }
- fontPath := filepath.Join(assetsDir, "fonts")
- if err := fm.loadFonts(fontPath); err != nil {
+ 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
}
@@ -136,7 +130,7 @@ type Glyph struct {
}
// loadFonts scans the fonts directory, loads all .ttf/.otf files, and creates a font map for each.
-func (fm *FontManager) loadFonts(fontDir string) error {
+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)
@@ -149,11 +143,7 @@ func (fm *FontManager) loadFonts(fontDir string) error {
fileName := file.Name()
if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") {
fontPath := filepath.Join(fontDir, fileName)
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("could not get standard_font_size from config: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
+ baseFontSize := cfg.StandardFontSize
if err != nil {
return fmt.Errorf("could not convert init_font_size to int: %w", err)
}
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 39f06f7..7db09ea 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -4,10 +4,10 @@ import (
"fmt"
"log"
"os"
- "sync"
"time"
"gorm.io/gorm"
+ "rhemawavetech.com/rhemawave/internal/db"
)
type Level int
@@ -24,78 +24,34 @@ func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
}
-type logEntry struct {
- timestamp string
- level string
- message string
-}
-
var (
- mu sync.Mutex
- db *gorm.DB
+ DB *gorm.DB
file *os.File
console = log.New(os.Stderr, "", 0)
-
- dbChan chan logEntry
- dbChanOnce sync.Once
)
-// startDBWriter starts a single background goroutine to drain log entries to the database.
-func startDBWriter() {
- dbChan = make(chan logEntry, 256)
- go func() {
- for entry := range dbChan {
- row := map[string]any{
- "timestamp": entry.timestamp,
- "level": entry.level,
- "message": entry.message,
- }
- if err := db.Table("log_entries").Create(row).Error; err != nil {
- mu.Lock()
- console.Printf("Failed to write log to database: %v", err)
- mu.Unlock()
- }
- }
- }()
-}
-
// AddFile attaches or replaces the file backend
func AddFile(path string) error {
- mu.Lock()
- defer mu.Unlock()
-
if file != nil {
file.Close()
}
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ _, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
- file = f
+
return nil
}
// AddDatabase attaches or replaces the database backend
func AddDatabase(d *gorm.DB) {
- mu.Lock()
- defer mu.Unlock()
- db = d
- dbChanOnce.Do(startDBWriter)
+ console.Println("Adding database to logger")
+ DB = d
}
-// Close shuts down the database writer goroutine and closes the file backend.
+// Close shuts down the file backend.
func Close() error {
- mu.Lock()
- defer mu.Unlock()
-
- // Stop the DB writer goroutine by closing the channel
- if dbChan != nil {
- close(dbChan)
- dbChan = nil
- db = nil
- }
-
if file != nil {
err := file.Close()
file = nil
@@ -106,24 +62,31 @@ func Close() error {
func logMsg(level Level, format string, args ...any) {
message := fmt.Sprintf(format, args...)
- timestamp := time.Now().Format("2006-01-02 15:04:05.000")
+ now := time.Now()
+ timestamp := now.Format("2006-01-02 15:04:05.000")
line := fmt.Sprintf("%s [%s] %s", timestamp, level, message)
- mu.Lock()
+ // 1. The standard log package is internally thread-safe
console.Println(line)
+ // 2. Write to file
if file != nil {
fmt.Fprintln(file, line)
}
- if db != nil && dbChan != nil { // both cleared by Close()
- select {
- case dbChan <- logEntry{timestamp: timestamp, level: level.String(), message: message}:
- default:
- // Channel full, drop the DB write rather than blocking
+ // 3. Write to DB using GORM. It handles its own connection pooling and locking.
+ if DB != nil {
+ var entry db.Log
+ entry.Timestamp = now.UnixMilli()
+ entry.Level = level.String()
+ entry.Message = message
+
+ // Use Save() to insert the new record.
+ result := DB.Save(&entry)
+ if result.Error != nil {
+ console.Printf("Failed to write log to database: %v\n", result.Error)
}
}
- mu.Unlock()
}
func Debug(format string, args ...any) { logMsg(LevelDebug, format, args...) }
diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go
index 5a8279f..42a82bb 100644
--- a/internal/mixer/mixer.go
+++ b/internal/mixer/mixer.go
@@ -4,7 +4,6 @@ package mixer
import (
"errors"
"fmt"
- "strconv"
"sync"
"github.com/veandco/go-sdl2/mix"
@@ -30,16 +29,20 @@ var (
defaultChannels = 2
defaultChunkSize = 1024
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
+
+ refreshCacheCallback func()
)
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
-func Init(wantedDevice string) error {
+func Init(cfg *db.AppConfig, onUpdate func()) error {
mu.Lock()
defer mu.Unlock()
if inited {
return nil
}
+ refreshCacheCallback = onUpdate
+
// Let SDL choose the best audio driver for the system
// Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc.
// Users can set SDL_AUDIODRIVER environment variable if needed
@@ -49,31 +52,21 @@ func Init(wantedDevice string) error {
}
// WAV-only does not require mix.Init(); add when you support OGG/MP3/etc.
- if err := openDeviceLocked(wantedDevice); err != nil {
+ if err := openDeviceLocked(cfg.SelectedAudioDevice); err != nil {
// Clean up the subsystem on failure so callers can retry later.
sdl.QuitSubSystem(sdl.INIT_AUDIO)
return err
}
- // Check if the volume is stored in the config; if not, default to max.
- storedVolume, err := db.GetConfigValue("last_volume")
- if err != nil {
- return fmt.Errorf("retrieving last_volume from config: %w", err)
- }
-
- lastVolume, err := strconv.Atoi(storedVolume)
- if err != nil {
- return fmt.Errorf("parsing last_volume %q: %w", storedVolume, err)
- }
-
- if lastVolume < 0 {
- lastVolume = int(mix.MAX_VOLUME)
- currentVolume = lastVolume
- if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
- }
- } else {
- currentVolume = lastVolume
- }
+ // TODO: Check if this logic is still needed; move to application
+ // if lastVolume < 0 {
+ // lastVolume = int(mix.MAX_VOLUME)
+ // currentVolume = lastVolume
+ // if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
+ // }
+ // } else {
+ // currentVolume = lastVolume
+ // }
// mix.VolumeMusic(currentVolume)
@@ -107,8 +100,8 @@ func openDeviceLocked(name string) error {
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
// If something is already playing, it stops and replaces it.
-func Play(path string, loopForever bool) error {
- if !db.AudioEnabled() {
+func Play(path string, loopForever bool, cfg *db.AppConfig) error {
+ if !cfg.AudioEnabled {
return nil
}
@@ -159,8 +152,8 @@ func Play(path string, loopForever bool) error {
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
// It also frees the currently loaded music (so a new Play will reload).
-func Stop(fadeMs int) {
- if !db.AudioEnabled() {
+func Stop(fadeMs int, cfg *db.AppConfig) {
+ if !cfg.AudioEnabled {
return
}
@@ -184,8 +177,8 @@ func Stop(fadeMs int) {
}
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
-func SetVolume128(increment int) {
- if !db.AudioEnabled() {
+func SetVolume128(increment int, cfg *db.AppConfig) {
+ if !cfg.AudioEnabled {
return
}
@@ -201,8 +194,8 @@ func SetVolume128(increment int) {
}
// Store in config
- if err := db.SetConfigValue("last_volume", strconv.Itoa(currentVolume)); err != nil {
- }
+ cfg.LastVolume = currentVolume
+ refreshCacheCallback()
// Apply to SDL_mixer
if inited && deviceOpen {
diff --git a/internal/netReq/netReq.go b/internal/netReq/netReq.go
new file mode 100644
index 0000000..a01adfa
--- /dev/null
+++ b/internal/netReq/netReq.go
@@ -0,0 +1,26 @@
+package netreq
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// 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(url string) ([]byte, error) {
+ fmt.Println(url)
+ res, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ body, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if res.StatusCode > 299 {
+ return nil, fmt.Errorf("request failed with status code %d: %s", res.StatusCode, string(body))
+ }
+ if err != nil {
+ return nil, err
+ }
+ return body, nil
+}
diff --git a/internal/netReq/netReq_test.go b/internal/netReq/netReq_test.go
new file mode 100644
index 0000000..ecebb23
--- /dev/null
+++ b/internal/netReq/netReq_test.go
@@ -0,0 +1,19 @@
+package netreq
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestGet(t *testing.T) {
+ url := "http://localhost:5173/api/v1/proclamations"
+ body, err := GetJSON(url)
+ if err != nil {
+ t.Fatalf("GetJSON failed: %v", err)
+ }
+ if len(body) == 0 {
+ t.Fatal("GetJSON returned empty body")
+ }
+ cnt := string(body)
+ fmt.Printf("%s\n", cnt)
+}
diff --git a/internal/page/audioDevices.go b/internal/page/audioDevices.go
index 325b80a..1bb7c3b 100644
--- a/internal/page/audioDevices.go
+++ b/internal/page/audioDevices.go
@@ -2,10 +2,10 @@ package page
import (
"fmt"
- "strconv"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
+ "gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
@@ -24,9 +24,11 @@ type AudioDevices struct {
highlightIndex int
selectedIndex int
verticalMargin int32
+ CfgPtr *db.AppConfig
+ dbPtr *gorm.DB
}
-func (p *AudioDevices) Init(app ApplicationInterface) error {
+func (p *AudioDevices) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
@@ -50,19 +52,12 @@ func (p *AudioDevices) Init(app ApplicationInterface) error {
}
// Initialize base
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
// Get font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
// Calculate scales
titleScale := float32(64) / float32(baseFontSize)
@@ -90,10 +85,7 @@ func (p *AudioDevices) Init(app ApplicationInterface) error {
}
// Get currently selected device from database
- currentDevice, err := db.GetConfigValue("audio_device_name")
- if err != nil {
- return fmt.Errorf("retrieving audio_device_name from db: %w", err)
- }
+ currentDevice := p.Base.Context.CfgPtr.SelectedAudioDevice
// Create UI items for each device
for i, name := range devices { // Iterate over the devices
@@ -111,13 +103,10 @@ func (p *AudioDevices) Init(app ApplicationInterface) error {
}
}
- on, err := db.GetConfigValue("audio_enabled")
- if err != nil {
- return fmt.Errorf("unable to query database for audio_enabled: %w", err)
- }
+ on := p.Base.Context.CfgPtr.AudioEnabled
audioStatus := "Turn Audio On"
- if on == "true" {
+ if on {
audioStatus = "Turn Audio Off"
}
@@ -177,22 +166,11 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
// Handle audio disable
if selection == "Turn Audio Off" || selection == "Turn Audio On" {
- on, err := db.GetConfigValue("audio_enabled")
- if err != nil {
- return fmt.Errorf("failed to fetch audio_enabled from database: %w", err)
- }
-
- currentStatus, err := strconv.ParseBool(on)
- if err != nil {
- return fmt.Errorf("failed to parse audio_enabled value: %w", err)
- }
-
- newStatus := strconv.FormatBool(!currentStatus)
- if err := db.SetConfigValue("audio_enabled", newStatus); err != nil {
- return fmt.Errorf("failed to update audio_enabled: %w", err)
- }
-
- p.App.UnwindToPage(&Settings{})
+ newStatus := !p.Base.Context.CfgPtr.AudioEnabled
+ updatedConfig := p.Base.Context.CfgPtr
+ updatedConfig.AudioEnabled = newStatus
+ p.Base.Context.DbPtr.Save(&updatedConfig)
+ p.Base.Context.RefreshConfig()
return nil
}
@@ -208,17 +186,12 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
}
// Save settings
- if err := db.SetConfigValue("audio_device_name", deviceName); err != nil {
- return fmt.Errorf("failed to save audio device: %w", err)
- }
- if err := db.SetConfigValue("audio_enabled", "true"); err != nil {
- return fmt.Errorf("failed to enable audio: %w", err)
- }
-
- p.App.UnwindToPage(&Settings{})
-
+ p.Base.Context.CfgPtr.SelectedAudioDevice = deviceName
+ p.Base.Context.CfgPtr.AudioEnabled = true
+ p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
+ p.Base.Context.RefreshConfig()
case sdl.K_BACKSPACE:
- p.App.UnwindToPage(&Settings{})
+ p.Base.Context.NavigateTo(SETTINGS)
}
return nil
diff --git a/internal/page/downloadProgressPage.go b/internal/page/downloadProgressPage.go
deleted file mode 100644
index 00e4224..0000000
--- a/internal/page/downloadProgressPage.go
+++ /dev/null
@@ -1,276 +0,0 @@
-package page
-
-import (
- "fmt"
- "strconv"
- "sync/atomic"
-
- "github.com/go-gl/gl/v3.3-core/gl"
- "github.com/go-gl/mathgl/mgl32"
- "github.com/veandco/go-sdl2/sdl"
- "rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
- "rhemawavetech.com/rhemawave/internal/fontManager"
- "rhemawavetech.com/rhemawave/internal/keybinds"
- "rhemawavetech.com/rhemawave/internal/shaderManager"
-)
-
-type downloadState int
-
-const (
- downloadStateDownloading downloadState = iota
- downloadStateFinished
-)
-
-// DownloadProgressPage displays a progress bar while an audio file is downloading.
-// The page is inescapable: no input is accepted while downloading, and only
-// Enter is accepted once the download finishes.
-type DownloadProgressPage struct {
- Base
- textShader *shaderManager.Shader
- solidShader *shaderManager.Shader
-
- // Set by the application before pushing this page.
- TotalBytes *atomic.Int64
- BytesWritten *atomic.Int64
- Done *atomic.Bool
- HasError *atomic.Bool
- NextPage Page // where to navigate on success
-
- state downloadState
- title StringItem
- prompt StringItem
- percentText StringItem
- lastPercent int
-
- barX, barY, barW, barH int32
-}
-
-func (p *DownloadProgressPage) Init(app ApplicationInterface) error {
- if err := p.Base.Init(app); err != nil {
- return fmt.Errorf("failed base page initialization: %w", err)
- }
-
- fm := fontManager.Get()
- sm := shaderManager.Get()
-
- font, ok := fm.GetFont("Roboto-Regular")
- if !ok {
- return fmt.Errorf("failed to get font")
- }
-
- p.textShader, ok = sm.Get("text")
- if !ok {
- return fmt.Errorf("failed to get text shader")
- }
- p.solidShader, ok = sm.Get("solid_color")
- if !ok {
- return fmt.Errorf("failed to get solid_color shader")
- }
-
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
-
- titleScale := float32(48) / float32(baseFontSize)
- p.title, err = NewStringItem("Downloading Audio...", font, colors.White)
- if err != nil {
- return fmt.Errorf("creating title: %w", err)
- }
- p.title.Scale(titleScale)
-
- promptScale := float32(32) / float32(baseFontSize)
- p.prompt, err = NewStringItem("Press Enter to continue", font, colors.White)
- if err != nil {
- return fmt.Errorf("creating prompt: %w", err)
- }
- p.prompt.Scale(promptScale)
-
- p.percentText, err = NewStringItem("0%", font, colors.White)
- if err != nil {
- return fmt.Errorf("creating percent text: %w", err)
- }
- p.percentText.Scale(promptScale)
- p.lastPercent = 0
-
- // Progress bar dimensions: 60% of screen width, 30px tall, centered
- p.barW = int32(float32(p.ScreenWidth) * 0.6)
- p.barH = 30
- p.barX = p.ScreenCenterX - p.barW/2
- p.barY = p.ScreenCenterY - p.barH/2
-
- // Position title above bar
- p.title.X = p.ScreenCenterX - p.title.W/2
- p.title.Y = p.barY + p.barH + 40
-
- p.state = downloadStateDownloading
- keybinds.Enabled = false
-
- return nil
-}
-
-func (p *DownloadProgressPage) Update(dt float32) error {
- if p.state == downloadStateFinished {
- return nil
- }
-
- if p.Done.Load() {
- p.state = downloadStateFinished
-
- // Force progress to 100% on success
- if !p.HasError.Load() && p.TotalBytes.Load() > 0 {
- p.BytesWritten.Store(p.TotalBytes.Load())
- p.lastPercent = 100
-
- fm := fontManager.Get()
- font, ok := fm.GetFont("Roboto-Regular")
- if ok {
- gl.DeleteTextures(1, &p.percentText.ID)
- baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
- baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
- percentScale := float32(32) / float32(baseFontSize)
- newText, err := NewStringItem("100%", font, colors.White)
- if err == nil {
- newText.Scale(percentScale)
- p.percentText = newText
- }
- }
- }
-
- // Update title to reflect result
- fm := fontManager.Get()
- font, ok := fm.GetFont("Roboto-Regular")
- if !ok {
- return nil
- }
-
- baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
- baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
- titleScale := float32(48) / float32(baseFontSize)
-
- gl.DeleteTextures(1, &p.title.ID)
-
- var titleText string
- if p.HasError.Load() {
- titleText = "Download Failed"
- } else {
- titleText = "Download Complete"
- }
- newTitle, err := NewStringItem(titleText, font, colors.White)
- if err == nil {
- newTitle.Scale(titleScale)
- p.title = newTitle
- p.title.X = p.ScreenCenterX - p.title.W/2
- p.title.Y = p.barY + p.barH + 40
- }
-
- return nil
- }
-
- // Update percentage texture when it changes
- percent := 0
- if p.TotalBytes.Load() > 0 {
- percent = int(p.BytesWritten.Load() * 100 / p.TotalBytes.Load())
- if percent > 100 {
- percent = 100
- }
- }
-
- if percent != p.lastPercent {
- p.lastPercent = percent
-
- fm := fontManager.Get()
- font, ok := fm.GetFont("Roboto-Regular")
- if !ok {
- return nil
- }
-
- gl.DeleteTextures(1, &p.percentText.ID)
-
- baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
- baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
- percentScale := float32(32) / float32(baseFontSize)
-
- newText, err := NewStringItem(fmt.Sprintf("%d%%", percent), font, colors.White)
- if err == nil {
- newText.Scale(percentScale)
- p.percentText = newText
- }
- }
-
- return nil
-}
-
-func (p *DownloadProgressPage) HandleEvent(event *sdl.Event) error {
- // Inescapable while downloading
- if p.state != downloadStateFinished {
- return nil
- }
-
- switch e := (*event).(type) {
- case *sdl.KeyboardEvent:
- if e.Type != sdl.KEYDOWN {
- return nil
- }
- if e.Keysym.Sym == sdl.K_RETURN {
- if p.HasError.Load() || p.NextPage == nil {
- p.App.SchedulePop()
- } else {
- p.App.SwitchPage(p.NextPage)
- }
- }
- }
- return nil
-}
-
-func (p *DownloadProgressPage) Render() error {
- // Render title
- p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
-
- // Progress bar background (dark gray)
- bgColor := sdl.Color{R: 60, G: 60, B: 60, A: 255}
- barPos := mgl32.Vec2{float32(p.barX), float32(p.barY)}
- barDim := mgl32.Vec2{float32(p.barW), float32(p.barH)}
- p.RenderSolidColorQuad(p.solidShader, barPos, barDim, bgColor)
-
- // Progress bar foreground
- progress := float32(0)
- if p.TotalBytes.Load() > 0 {
- progress = float32(p.BytesWritten.Load()) / float32(p.TotalBytes.Load())
- if progress > 1.0 {
- progress = 1.0
- }
- }
- if progress > 0 {
- fgColor := sdl.Color{R: 255, G: 255, B: 255, A: 255}
- fgDim := mgl32.Vec2{float32(p.barW) * progress, float32(p.barH)}
- p.RenderSolidColorQuad(p.solidShader, barPos, fgDim, fgColor)
- }
-
- // Render percentage text below bar
- p.percentText.X = p.ScreenCenterX - p.percentText.W/2
- p.percentText.Y = p.barY - p.percentText.H - 20
- p.RenderTexture(p.textShader, p.percentText.ID, p.percentText.Position(), p.percentText.RawDimensions(), p.percentText.Color)
-
- // Show prompt when finished
- if p.state == downloadStateFinished {
- p.prompt.X = p.ScreenCenterX - p.prompt.W/2
- p.prompt.Y = p.barY - p.percentText.H - 20 - p.prompt.H - 20
- p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
- }
-
- return nil
-}
-
-func (p *DownloadProgressPage) Destroy() error {
- keybinds.Enabled = true
- gl.DeleteTextures(1, &p.title.ID)
- gl.DeleteTextures(1, &p.prompt.ID)
- gl.DeleteTextures(1, &p.percentText.ID)
- return nil
-}
diff --git a/internal/page/emailAddressPage.go b/internal/page/emailAddressPage.go
index 001f9e6..c27e20b 100644
--- a/internal/page/emailAddressPage.go
+++ b/internal/page/emailAddressPage.go
@@ -3,13 +3,11 @@ package page
import (
"fmt"
"regexp"
- "strconv"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
@@ -53,19 +51,15 @@ func isValidEmail(email string) bool {
}
// Init performs basic page initialization and creation of static assets.
-func (p *EmailAddressPage) Init(app ApplicationInterface) error {
+func (p *EmailAddressPage) Init(ctx *PageCtx) error {
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
p.title = "Your Email Address"
- // Get application font from database
- applicationFont, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFont := "Roboto-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
@@ -87,10 +81,7 @@ func (p *EmailAddressPage) Init(app ApplicationInterface) error {
p.titleTextureHeight = titleHeight
// Load existing email address from database
- emailAddress, err := db.GetConfigValue("email_address")
- if err != nil {
- return fmt.Errorf("retrieving email_address from db: %w", err)
- }
+ emailAddress := ctx.CfgPtr.EmailAddress
if emailAddress != "" {
p.inputText = emailAddress
@@ -148,7 +139,7 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
}
// Allow escape back to settings if coming from settings
sdl.StopTextInput()
- p.App.UnwindToPage(&Settings{})
+ p.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN:
// Validate email - check empty first, then format
if p.inputText == "" {
@@ -163,16 +154,15 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
sdl.StopTextInput()
// Save email address to database
- if err := db.SetConfigValue("email_address", p.inputText); err != nil {
- return fmt.Errorf("failed to save email address: %w", err)
- }
+ p.Base.Context.CfgPtr.EmailAddress = p.inputText
+ p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
+ p.Base.Context.RefreshConfig()
if p.isFirstTimeSetup {
// First-time setup: navigate to License Key page
- p.App.PushPage(&LicenseKeyPage{})
+ p.Context.NavigateTo(LICENSE_KEY)
} else {
- // Coming from settings: rewind back to settings
- p.App.UnwindToPage(&Settings{})
+ p.Context.NavigateTo(SETTINGS)
}
case sdl.K_BACKSPACE:
if len(p.inputText) > 0 {
@@ -199,10 +189,7 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
func (p *EmailAddressPage) Update(dt float32) error {
// Get application font from database
- applicationFont, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFont := "Roboto-Regular"
fm := fontManager.Get()
sm := shaderManager.Get()
@@ -266,14 +253,7 @@ func (p *EmailAddressPage) Update(dt float32) error {
// Render performs page drawing logic.
func (p *EmailAddressPage) Render() error {
// Get base font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
sm := shaderManager.Get()
diff --git a/internal/page/welcome.go b/internal/page/home.go
similarity index 88%
rename from internal/page/welcome.go
rename to internal/page/home.go
index 8123b19..120fdd0 100644
--- a/internal/page/welcome.go
+++ b/internal/page/home.go
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strconv"
"strings"
"time"
@@ -12,14 +11,13 @@ import (
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
var GitVersion = "dev"
-type Welcome struct {
+type Home struct {
Base
title StringItem
version StringItem
@@ -70,7 +68,8 @@ func countSDLDisplays() int {
}
// updateDisplayCounts updates the display count textures
-func (p *Welcome) updateDisplayCounts() error {
+func (p *Home) updateDisplayCounts() error {
+ var err error
// Get font
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
@@ -79,14 +78,7 @@ func (p *Welcome) updateDisplayCounts() error {
}
// Get base font size
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
// Update DRM count
p.drmDisplayCount = countConnectedDisplays()
@@ -127,14 +119,14 @@ func (p *Welcome) updateDisplayCounts() error {
}
// Init performs basic page initialization and creation of static assets.
-func (p *Welcome) Init(app ApplicationInterface) error {
+func (p *Home) Init(ctx *PageCtx) error {
title := "Rhema Wave"
version := GitVersion
prompt := "Press F2 to Start, or F3 for Settings"
note := "You can always press F1 to return here"
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
@@ -177,19 +169,8 @@ func (p *Welcome) Init(app ApplicationInterface) error {
return fmt.Errorf("unable to update display counts: %w", err)
}
- // Load license information from database
- emailAddress, err := db.GetConfigValue("email_address")
- if err != nil {
- return fmt.Errorf("retrieving email_address from db: %w", err)
- }
-
- licenseKey, err := db.GetConfigValue("license_key")
- if err != nil {
- return fmt.Errorf("retrieving license_key from db: %w", err)
- }
-
// Create license info textures if data exists
- if emailAddress != "" || licenseKey != "" {
+ if p.Base.Context.CfgPtr.EmailAddress != "" || p.Base.Context.CfgPtr.LicenseKey != "" {
p.hasLicenseInfo = true
// Create heading
@@ -199,8 +180,8 @@ func (p *Welcome) Init(app ApplicationInterface) error {
}
// Create email display if exists
- if emailAddress != "" {
- emailText := fmt.Sprintf("Email: %s", emailAddress)
+ if p.Base.Context.CfgPtr.EmailAddress != "" {
+ emailText := fmt.Sprintf("Email: %s", p.Base.Context.CfgPtr.EmailAddress)
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create email display texture: %w", err)
@@ -208,8 +189,8 @@ func (p *Welcome) Init(app ApplicationInterface) error {
}
// Create license key display if exists
- if licenseKey != "" {
- licenseKeyText := fmt.Sprintf("License Key: %s", licenseKey)
+ if p.Base.Context.CfgPtr.LicenseKey != "" {
+ licenseKeyText := fmt.Sprintf("License Key: %s", p.Base.Context.CfgPtr.LicenseKey)
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
if err != nil {
return fmt.Errorf("unable to create license key display texture: %w", err)
@@ -218,14 +199,7 @@ func (p *Welcome) Init(app ApplicationInterface) error {
}
// Get base font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
p.baseFontSize = baseFontSize
@@ -249,13 +223,13 @@ func (p *Welcome) Init(app ApplicationInterface) error {
return nil
}
-// HandleEvent processes SDL events and handles them appropriately for the Welcome page.
-func (p *Welcome) HandleEvent(event *sdl.Event) error {
+// HandleEvent processes SDL events and handles them appropriately for the Home page.
+func (p *Home) HandleEvent(event *sdl.Event) error {
return nil
}
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
-func (p *Welcome) Update(dt float32) error {
+func (p *Home) Update(dt float32) error {
// Update display counts every 2 seconds
if time.Since(p.lastDisplayUpdate) >= 2*time.Second {
if err := p.updateDisplayCounts(); err != nil {
@@ -265,7 +239,7 @@ func (p *Welcome) Update(dt float32) error {
return nil
}
-func (p *Welcome) Render() error {
+func (p *Home) Render() error {
baseFontSize := p.baseFontSize
sm := shaderManager.Get()
verticalMargin := int32(20)
@@ -417,7 +391,7 @@ func (p *Welcome) Render() error {
}
// Destroy cleans up page-specific resources.
-func (p *Welcome) Destroy() error {
+func (p *Home) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.version.ID)
gl.DeleteTextures(1, &p.prompt.ID)
diff --git a/internal/page/licenseKeyPage.go b/internal/page/licenseKeyPage.go
index 0283f1b..8bf1061 100644
--- a/internal/page/licenseKeyPage.go
+++ b/internal/page/licenseKeyPage.go
@@ -4,14 +4,12 @@ import (
"fmt"
"os"
"path/filepath"
- "strconv"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
@@ -47,34 +45,17 @@ type LicenseKeyPage struct {
textInputSupported bool
}
-// isValidLicenseKey checks if the license key has exactly 16 alphanumeric characters
-func isValidLicenseKey(key string) bool {
- cleaned := strings.ReplaceAll(key, "-", "")
- if len(cleaned) != 16 {
- return false
- }
- for _, r := range cleaned {
- if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
- return false
- }
- }
- return true
-}
-
// Init performs basic page initialization and creation of static assets.
-func (p *LicenseKeyPage) Init(app ApplicationInterface) error {
+func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
- if err := p.Base.Init(app); err != nil {
+ 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, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFont := "Roboto-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
@@ -96,10 +77,7 @@ func (p *LicenseKeyPage) Init(app ApplicationInterface) error {
p.titleTextureHeight = titleHeight
// Load existing license key from database
- licenseKey, err := db.GetConfigValue("license_key")
- if err != nil {
- return fmt.Errorf("retrieving license_key from db: %w", err)
- }
+ licenseKey := p.Base.Context.CfgPtr.LicenseKey
if licenseKey != "" {
p.inputText = licenseKey
@@ -187,7 +165,7 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
}
// Allow escape back to settings if coming from settings
sdl.StopTextInput()
- p.App.UnwindToPage(&Settings{})
+ p.Base.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN:
// Validate license key - check empty first, then length, then characters
cleaned := strings.ReplaceAll(p.inputText, "-", "")
@@ -210,9 +188,9 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
sdl.StopTextInput()
// Save license key to database
- if err := db.SetConfigValue("license_key", p.inputText); err != nil {
- return fmt.Errorf("failed to save license key: %w", err)
- }
+ p.Base.Context.CfgPtr.LicenseKey = p.inputText
+ p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
+ p.Base.Context.RefreshConfig()
// Write license key to plain text file for the updater
if homeDir, err := os.UserHomeDir(); err == nil {
@@ -224,10 +202,10 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
if p.isFirstTimeSetup {
// First-time setup: rewind to Welcome page
- p.App.UnwindToPage(&Welcome{})
+ p.Base.Context.NavigateTo(HOME)
} else {
// Coming from settings: rewind back to settings
- p.App.UnwindToPage(&Settings{})
+ p.Base.Context.NavigateTo(SETTINGS)
}
case sdl.K_BACKSPACE:
if len(p.inputText) > 0 {
@@ -261,10 +239,7 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
func (p *LicenseKeyPage) Update(dt float32) error {
// Get application font from database
- applicationFont, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFont := "Roboto-Regular"
fm := fontManager.Get()
sm := shaderManager.Get()
@@ -328,14 +303,7 @@ func (p *LicenseKeyPage) Update(dt float32) error {
// Render performs page drawing logic.
func (p *LicenseKeyPage) Render() error {
// Get base font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
sm := shaderManager.Get()
diff --git a/internal/page/logViewerPage.go b/internal/page/logViewerPage.go
index 6f8878e..7470d21 100644
--- a/internal/page/logViewerPage.go
+++ b/internal/page/logViewerPage.go
@@ -2,14 +2,15 @@ package page
import (
"fmt"
- "strconv"
"strings"
+ "time"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
+ "rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
@@ -35,27 +36,32 @@ type LogViewerPage struct {
visibleHeight int32
}
-func (p *LogViewerPage) Init(app ApplicationInterface) error {
- if err := p.Base.Init(app); err != nil {
+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)
}
- logs, err := db.GetLogs(1000)
- if err != nil {
- return fmt.Errorf("failed to get logs: %w", err)
+ if result := p.Base.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")...)
+ // }
+
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")...)
+ logTime := time.UnixMilli(entry.Timestamp)
+ timeStr := logTime.Format("2006-01-02 15:04:05")
+ line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
+ p.lines = append(p.lines, line)
}
- applicationFont, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFont := "Roboto-Regular"
fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text")
@@ -109,7 +115,7 @@ func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym {
case sdl.K_BACKSPACE:
- p.App.SchedulePop()
+ p.Base.Context.NavigateTo(SETTINGS)
case sdl.K_UP:
p.scrollOffset -= p.lineHeight
if p.scrollOffset < 0 {
@@ -153,14 +159,7 @@ func (p *LogViewerPage) Update(dt float32) error {
}
func (p *LogViewerPage) Render() error {
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
sm := shaderManager.Get()
textShader, _ := sm.Get("text")
diff --git a/internal/page/page.go b/internal/page/page.go
index aa89f29..b3c0e65 100644
--- a/internal/page/page.go
+++ b/internal/page/page.go
@@ -12,29 +12,16 @@ import (
// Page defines the contract for all application screens.
type Page interface {
- Init(app ApplicationInterface) error
+ Init(ctx *PageCtx) error
HandleEvent(event *sdl.Event) error
Update(dt float32) error
Render() error
- // Destroy cleans up the page's resources. The Application's page manager
- // is responsible for calling this method
Destroy() error
}
-// ApplicationInterface defines the set of methods that a page can use to
-// interact with the main application.
-type ApplicationInterface interface {
- SwitchPage(p Page)
- PushPage(p Page)
- UnwindToPage(p Page)
- GetDrawableSize() (int32, int32)
- SchedulePop()
- Quit()
-}
-
// Base is a helper struct that pages can embed to get common functionality.
type Base struct {
- App ApplicationInterface
+ Context *PageCtx
QuadVAO uint32
QuadVBO uint32
Projection mgl32.Mat4
@@ -45,9 +32,8 @@ type Base struct {
}
// Init stores the application context and sets up common resources.
-func (p *Base) Init(app ApplicationInterface) error {
- p.App = app
-
+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,
@@ -59,12 +45,14 @@ func (p *Base) Init(app ApplicationInterface) error {
1.0, 0.0, 1.0, 0.0,
}
- screenWidth, screenHeight := p.App.GetDrawableSize()
- p.Projection = mgl32.Ortho(0, float32(screenWidth), 0, float32(screenHeight), -1, 1)
- p.ScreenHeight = screenHeight
- p.ScreenWidth = screenWidth
- p.ScreenCenterX = screenWidth / 2
- p.ScreenCenterY = screenHeight / 2
+ 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)
diff --git a/internal/page/proclamationOptions.go b/internal/page/proclamationOptions.go
index d9d1516..414f333 100644
--- a/internal/page/proclamationOptions.go
+++ b/internal/page/proclamationOptions.go
@@ -2,35 +2,35 @@ package page
import (
"fmt"
- "strconv"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
+ "rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
type ProclamationOptions struct {
Base
- font *fontManager.FontEntry
- textShader *shaderManager.Shader
- solidShader *shaderManager.Shader
- title StringItem
- prompt StringItem
+ font *fontManager.FontEntry
+ textShader *shaderManager.Shader
+ solidShader *shaderManager.Shader
+ title StringItem
+ prompt StringItem
proclamations []StringItem
- proclamationIDs []uint // Database IDs corresponding to each proclamation
- activeMap map[int]bool // Maps display index to selected state
- highlightIndex int
- verticalMargin int32
+ proclamationIDs []string
+ activeMap map[int]bool // Maps display index to selected state
+ highlightIndex int
+ verticalMargin int32
}
-func (p *ProclamationOptions) Init(app ApplicationInterface) error {
+func (p *ProclamationOptions) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.activeMap = make(map[int]bool)
- p.proclamationIDs = make([]uint, 0)
+ p.proclamationIDs = make([]string, 0)
// Get font
fm := fontManager.Get()
@@ -53,19 +53,12 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
}
// Initialize base
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
// Get font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
- }
+ baseFontSize := ctx.CfgPtr.StandardFontSize
// Calculate scales
titleScale := float32(64) / float32(baseFontSize)
@@ -86,9 +79,10 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
p.prompt.Scale(itemScale)
// Load proclamations from database
- proclamations, err := db.GetProclamations()
- if err != nil {
- return fmt.Errorf("retrieving proclamations from db: %w", err)
+ var proclamations []db.Proclamation
+ if result := p.Context.DbPtr.Find(&proclamations); result.Error != nil {
+ logger.Error("unable to query database for proclamations: %v", result.Error)
+ return fmt.Errorf("%s", result.Error)
}
// Create UI items for each available proclamation
@@ -97,7 +91,7 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
continue
}
- label, err := NewStringItem(proclamation.Title, p.font, colors.White)
+ label, err := NewStringItem(proclamation.Name, p.font, colors.White)
if err != nil {
return fmt.Errorf("creating proclamation label: %w", err)
}
@@ -152,7 +146,7 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
p.highlightIndex++
}
case sdl.K_BACKSPACE:
- p.App.UnwindToPage(&Settings{})
+ p.Context.NavigateTo(SETTINGS)
case sdl.K_SPACE:
// Toggle selection for highlighted item
if p.activeMap[p.highlightIndex] {
@@ -167,7 +161,7 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
}
case sdl.K_RETURN:
// Just return to settings - no saving needed
- p.App.UnwindToPage(&Settings{})
+ p.Context.NavigateTo(SETTINGS)
}
}
return nil
@@ -183,15 +177,17 @@ func (p *ProclamationOptions) toggleProclamation(displayIndex int) error {
isSelected := p.activeMap[displayIndex]
// Get the proclamation from database
- var proclamation db.Proclamations
- if err := db.DB.First(&proclamation, dbID).Error; err != nil {
- return fmt.Errorf("retrieving proclamation %d: %w", dbID, err)
+ var proclamation db.Proclamation
+ proclamation.ID = dbID
+ if result := p.Context.DbPtr.Find(&proclamation); result.Error != nil {
+ logger.Error("unable to query proclamation %s from database: %v", proclamation.ID, result.Error)
}
// Update the Selected field
proclamation.Selected = isSelected
- if err := db.DB.Save(&proclamation).Error; err != nil {
- return fmt.Errorf("updating proclamation %d: %w", dbID, err)
+ if result := p.Context.DbPtr.Save(&proclamation); result.Error != nil {
+ logger.Error("failed to update proclamation %s: %v", proclamation.ID, result.Error)
+ return fmt.Errorf("%s", result.Error)
}
return nil
diff --git a/internal/page/router.go b/internal/page/router.go
new file mode 100644
index 0000000..53236cd
--- /dev/null
+++ b/internal/page/router.go
@@ -0,0 +1,75 @@
+package page
+
+import (
+ "gorm.io/gorm"
+ "rhemawavetech.com/rhemawave/internal/db"
+)
+
+type pageType int
+
+const (
+ HOME pageType = iota
+ SCROLLER
+ SETTINGS
+ WIFI
+ AUDIO
+ PROCLAMATIONS
+ LICENSE_KEY
+ EMAIL_ADDRESS
+ LOGS
+ CHECK_UPDATES
+ ERROR
+)
+
+type PageCtx struct {
+ CfgPtr *db.AppConfig
+ DbPtr *gorm.DB
+ W, H int32
+ RefreshConfig func()
+ NavigateTo func(pageType)
+}
+
+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 = &AudioDevices{}
+ case PROCLAMATIONS:
+ r.ActivePage = &ProclamationOptions{}
+ case LICENSE_KEY:
+ r.ActivePage = &LicenseKeyPage{}
+ case EMAIL_ADDRESS:
+ r.ActivePage = &EmailAddressPage{}
+ case LOGS:
+ r.ActivePage = &LogViewerPage{}
+ case CHECK_UPDATES:
+ r.ActivePage = &UpdateCheckPage{}
+ }
+
+ r.ActivePage.Init(&r.Context)
+}
diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go
index e12304b..bf80f31 100644
--- a/internal/page/scrollerPage.go
+++ b/internal/page/scrollerPage.go
@@ -60,7 +60,6 @@ import (
"fmt"
"math"
"path/filepath"
- "strconv"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
@@ -194,7 +193,7 @@ type ScrollerPage struct {
hold float32
}
-func (p *ScrollerPage) Init(app ApplicationInterface) error {
+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)
@@ -209,7 +208,7 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
}
p.font = font
- p.Base.Init(app)
+ p.Base.Init(ctx)
// Get text shader
textShader, ok := sm.Get("text")
@@ -226,10 +225,8 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
p.wordIndex = 0
// Get line pattern from database
- linePatternName, err := db.GetConfigValue("line_pattern")
- if err != nil {
- return fmt.Errorf("retrieving line_pattern from db: %w", err)
- }
+ var err error
+ linePatternName := p.Base.Context.CfgPtr.LinePattern
p.activePattern, err = linePattern.Get(linePatternName)
if err != nil {
@@ -249,23 +246,8 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
p.colorPattern = colorPattern.NewColorPattern(baseColors)
// Get font sizes from database (optimize: load once, cache as int32)
- standardFontSizeStr, err := db.GetConfigValue("standard_font_size")
- if err != nil {
- return fmt.Errorf("retrieving standard_font_size from db: %w", err)
- }
- standardFontSize, err := strconv.Atoi(standardFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing standard_font_size: %w", err)
- }
-
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ standardFontSize := p.Base.Context.CfgPtr.StandardFontSize
+ baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
// Font setup (optimized: calculate once, reuse)
p.fontScale = float32(standardFontSize) / float32(baseFontSize)
@@ -302,10 +284,7 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
}
// Audio setup
- audioDeviceName, err := db.GetConfigValue("audio_device_name")
- if err != nil {
- return fmt.Errorf("retrieving audio_device_name from db: %w", err)
- }
+ audioDeviceName := p.Base.Context.CfgPtr.SelectedAudioDevice
// Switch audio devices if needed
if mixer.CurrentDevice() != audioDeviceName {
@@ -315,19 +294,13 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
}
// Start audio playback
- selectedAudio, err := db.GetConfigValue("selected_audio")
- if err != nil {
- return fmt.Errorf("retrieving selected_audio from db: %w", err)
- }
+ selectedAudio := p.Base.Context.CfgPtr.SelectedAudio
- assetsDir, err := db.GetConfigValue("assets_dir")
- if err != nil {
- return fmt.Errorf("retrieving assets_dir from db: %w", err)
- }
+ assetsDir := p.Base.Context.CfgPtr.AssetsDir
if selectedAudio == "" {
} else {
- if err := mixer.Play(filepath.Join(assetsDir, selectedAudio), true); err != nil {
+ if err := mixer.Play(filepath.Join(assetsDir, selectedAudio), true, p.Base.Context.CfgPtr); err != nil {
}
}
@@ -344,10 +317,8 @@ func (p *ScrollerPage) loadProclamations() error {
p.words = []string{}
// Get all selected proclamations from database
- proclamations, err := db.GetProclamations()
- if err != nil {
- return fmt.Errorf("retrieving proclamations from db: %w", err)
- }
+ var proclamations []db.Proclamation
+ p.Base.Context.DbPtr.Find(&proclamations)
// Build word list from selected, available proclamations
for _, proclamation := range proclamations {
@@ -614,7 +585,7 @@ func (p *ScrollerPage) Destroy() error {
return nil
}
- mixer.Stop(1000)
+ mixer.Stop(1000, p.Context.CfgPtr)
// GL cleanup — delete all text textures
for i := 0; i < p.lines.Len(); i++ {
diff --git a/internal/page/settingsPage.go b/internal/page/settingsPage.go
index 6ba9ee4..7d0d6e3 100644
--- a/internal/page/settingsPage.go
+++ b/internal/page/settingsPage.go
@@ -2,12 +2,10 @@ package page
import (
"fmt"
- "strconv"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
@@ -26,11 +24,11 @@ type Settings struct {
}
type Option struct {
- page Page
- label StringItem
+ target pageType
+ label StringItem
}
-func (p *Settings) Init(app ApplicationInterface) error {
+func (p *Settings) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
@@ -54,7 +52,7 @@ func (p *Settings) Init(app ApplicationInterface) error {
}
// Initialize base
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
@@ -76,8 +74,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating proclamations label: %w", err)
}
p.options = append(p.options, Option{
- page: &ProclamationOptions{},
- label: proclamationsLabel,
+ target: PROCLAMATIONS,
+ label: proclamationsLabel,
})
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
@@ -85,8 +83,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating wifi label: %w", err)
}
p.options = append(p.options, Option{
- page: &WiFiSetupPage{},
- label: wifiLabel,
+ target: WIFI,
+ label: wifiLabel,
})
audioLabel, err := NewStringItem("Audio Device", font, colors.White)
@@ -94,8 +92,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating audio label: %w", err)
}
p.options = append(p.options, Option{
- page: &AudioDevices{},
- label: audioLabel,
+ target: AUDIO,
+ label: audioLabel,
})
emailLabel, err := NewStringItem("Email Address", font, colors.White)
@@ -103,8 +101,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating email label: %w", err)
}
p.options = append(p.options, Option{
- page: &EmailAddressPage{},
- label: emailLabel,
+ target: EMAIL_ADDRESS,
+ label: emailLabel,
})
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White)
@@ -112,8 +110,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating license key label: %w", err)
}
p.options = append(p.options, Option{
- page: &LicenseKeyPage{},
- label: licenseKeyLabel,
+ target: LICENSE_KEY,
+ label: licenseKeyLabel,
})
logsLabel, err := NewStringItem("Logs", font, colors.White)
@@ -121,8 +119,8 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating logs label: %w", err)
}
p.options = append(p.options, Option{
- page: &LogViewerPage{},
- label: logsLabel,
+ target: LOGS,
+ label: logsLabel,
})
updateLabel, err := NewStringItem("Check for Updates", font, colors.White)
@@ -130,19 +128,12 @@ func (p *Settings) Init(app ApplicationInterface) error {
return fmt.Errorf("creating update label: %w", err)
}
p.options = append(p.options, Option{
- page: &UpdateCheckPage{},
- label: updateLabel,
+ target: CHECK_UPDATES,
+ label: updateLabel,
})
// Get base font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Context.CfgPtr.StandardFontSize
// Calculate scales
titleScale := float32(64) / float32(baseFontSize)
@@ -208,10 +199,10 @@ func (p *Settings) HandleEvent(event *sdl.Event) error {
case sdl.K_RETURN:
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
selectedOption := p.options[p.highlightIndex]
- p.App.SwitchPage(selectedOption.page)
+ p.Context.NavigateTo(selectedOption.target)
}
case sdl.K_BACKSPACE:
- p.App.SwitchPage(&Welcome{})
+ p.Context.NavigateTo(HOME)
}
}
return nil
diff --git a/internal/page/tosPage.go b/internal/page/tosPage.go
deleted file mode 100644
index 1e549a0..0000000
--- a/internal/page/tosPage.go
+++ /dev/null
@@ -1,388 +0,0 @@
-package page
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- "github.com/go-gl/gl/v3.3-core/gl"
- "github.com/go-gl/mathgl/mgl32"
- "github.com/veandco/go-sdl2/sdl"
- "rhemawavetech.com/rhemawave/internal/db"
- "rhemawavetech.com/rhemawave/internal/fontManager"
- "rhemawavetech.com/rhemawave/internal/logger"
- "rhemawavetech.com/rhemawave/internal/shaderManager"
-)
-
-type TOSPage struct {
- Base
-
- Title string
- Content string
- ConfigKey string
-
- titleTextureID uint32
- titleTextureWidth int32
- titleTextureHeight int32
-
- lines []string
- lineTextureIDs []uint32
- lineTextureWidths []int32
- lineTextureHeights []int32
-
- promptTextureID uint32
- promptTextureWidth int32
- promptTextureHeight int32
-
- scrollHintTextureID uint32
- scrollHintTextureWidth int32
- scrollHintTextureHeight int32
-
- scrollOffset int32
- maxScrollOffset int32
- lineHeight int32
- visibleHeight int32
-}
-
-func (p *TOSPage) Init(app ApplicationInterface) error {
- if err := p.Base.Init(app); err != nil {
- return fmt.Errorf("failed base page initialization: %w", err)
- }
-
- p.lines = parseHTMLToLines(p.Content, 60)
-
- applicationFont, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
-
- fm := fontManager.Get()
- textShader, ok := shaderManager.Get().Get("text")
- if !ok {
- return fmt.Errorf("unable to fetch text shader")
- }
-
- titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.Title, textShader)
- if err != nil {
- return fmt.Errorf("failed to create title texture: %w", err)
- }
- p.titleTextureID = titleTextureID
- p.titleTextureWidth = titleWidth
- p.titleTextureHeight = titleHeight
-
- p.lineTextureIDs = make([]uint32, len(p.lines))
- p.lineTextureWidths = make([]int32, len(p.lines))
- p.lineTextureHeights = make([]int32, len(p.lines))
-
- for i, line := range p.lines {
- if strings.TrimSpace(line) == "" {
- continue
- }
- texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader)
- if err != nil {
- return fmt.Errorf("failed to create line texture: %w", err)
- }
- p.lineTextureIDs[i] = texID
- p.lineTextureWidths[i] = w
- p.lineTextureHeights[i] = h
- }
-
- promptText := "Press Y to Accept | Press N to Decline"
- promptTextureID, promptWidth, promptHeight, err := fm.CreateStringTexture(applicationFont, promptText, textShader)
- if err != nil {
- return fmt.Errorf("failed to create prompt texture: %w", err)
- }
- p.promptTextureID = promptTextureID
- p.promptTextureWidth = promptWidth
- p.promptTextureHeight = promptHeight
-
- scrollHintText := "Scroll down: Arrow Keys / PgDn"
- scrollHintID, scrollHintW, scrollHintH, err := fm.CreateStringTexture(applicationFont, scrollHintText, textShader)
- if err != nil {
- return fmt.Errorf("failed to create scroll hint texture: %w", err)
- }
- p.scrollHintTextureID = scrollHintID
- p.scrollHintTextureWidth = scrollHintW
- p.scrollHintTextureHeight = scrollHintH
-
- return nil
-}
-
-// parseHTMLToLines converts HTML content to plain-text lines for rendering.
-// Supported tags: ,
,
, -. All other tags are stripped.
-// HTML entities & < > " ' are decoded.
-func parseHTMLToLines(html string, maxCharsPerLine int) []string {
- var lines []string
-
- // Replace
,
,
with newline markers
- br := strings.NewReplacer(
- "
", "\n", "
", "\n", "
", "\n",
- "
", "\n", "
", "\n", "
", "\n",
- )
- html = br.Replace(html)
-
- // Process block-level tags
- // Replace
, - with paragraph break marker
- for _, tag := range []string{"", "", "", "", "", "", ""} {
- html = strings.ReplaceAll(html, tag, "\n\n")
- html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
- }
-
- // Replace with list prefix marker
- html = strings.ReplaceAll(html, "", "\n- ")
- html = strings.ReplaceAll(html, "", "\n- ")
-
- // Strip all remaining HTML tags
- var stripped strings.Builder
- inTag := false
- for _, r := range html {
- if r == '<' {
- inTag = true
- continue
- }
- if r == '>' {
- inTag = false
- continue
- }
- if !inTag {
- stripped.WriteRune(r)
- }
- }
- text := stripped.String()
-
- // Decode HTML entities
- text = strings.ReplaceAll(text, "&", "&")
- text = strings.ReplaceAll(text, "<", "<")
- text = strings.ReplaceAll(text, ">", ">")
- text = strings.ReplaceAll(text, """, "\"")
- text = strings.ReplaceAll(text, "'", "'")
-
- // Split into segments on double-newline (paragraph breaks) and single newline (line breaks)
- paragraphs := strings.Split(text, "\n\n")
- for i, para := range paragraphs {
- para = strings.TrimSpace(para)
- if para == "" {
- continue
- }
-
- // Handle single newlines within a paragraph (from
tags)
- sublines := strings.Split(para, "\n")
- for _, sub := range sublines {
- sub = strings.TrimSpace(sub)
- if sub == "" {
- continue
- }
- wrapped := wrapText(sub, maxCharsPerLine)
- lines = append(lines, strings.Split(wrapped, "\n")...)
- }
-
- // Add paragraph break (empty line) between paragraphs
- if i < len(paragraphs)-1 {
- lines = append(lines, "")
- }
- }
-
- return lines
-}
-
-func wrapText(text string, maxCharsPerLine int) string {
- var result strings.Builder
- words := strings.Fields(text)
- lineLen := 0
-
- for _, word := range words {
- if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 {
- result.WriteString("\n")
- lineLen = 0
- }
- if lineLen > 0 {
- result.WriteString(" ")
- lineLen++
- }
- result.WriteString(word)
- lineLen += len(word)
- }
- return result.String()
-}
-
-func (p *TOSPage) HandleEvent(event *sdl.Event) error {
- actualEvent := *event
-
- switch e := actualEvent.(type) {
- case *sdl.KeyboardEvent:
- if e.Type == sdl.KEYDOWN {
- switch e.Keysym.Sym {
- case sdl.K_n:
- logger.Info("User declined TOS (%s); exiting", p.ConfigKey)
- p.App.Quit()
- case sdl.K_y:
- if p.scrollOffset < p.maxScrollOffset {
- return nil
- }
- logger.Info("User accepted TOS (%s)", p.ConfigKey)
- if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil {
- return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err)
- }
- p.App.SchedulePop()
- case sdl.K_UP:
- p.scrollOffset -= p.lineHeight
- if p.scrollOffset < 0 {
- p.scrollOffset = 0
- }
- case sdl.K_DOWN:
- p.scrollOffset += p.lineHeight
- if p.scrollOffset > p.maxScrollOffset {
- p.scrollOffset = p.maxScrollOffset
- }
- case sdl.K_PAGEUP:
- p.scrollOffset -= p.visibleHeight
- if p.scrollOffset < 0 {
- p.scrollOffset = 0
- }
- case sdl.K_PAGEDOWN:
- p.scrollOffset += p.visibleHeight
- if p.scrollOffset > p.maxScrollOffset {
- p.scrollOffset = p.maxScrollOffset
- }
- case sdl.K_HOME:
- p.scrollOffset = 0
- case sdl.K_END:
- p.scrollOffset = p.maxScrollOffset
- }
- }
- case *sdl.MouseWheelEvent:
- p.scrollOffset -= e.Y * p.lineHeight * 3
- if p.scrollOffset < 0 {
- p.scrollOffset = 0
- }
- if p.scrollOffset > p.maxScrollOffset {
- p.scrollOffset = p.maxScrollOffset
- }
- }
-
- return nil
-}
-
-func (p *TOSPage) Update(dt float32) error {
- return nil
-}
-
-func (p *TOSPage) Render() error {
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
-
- sm := shaderManager.Get()
- textShader, _ := sm.Get("text")
-
- white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
- grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
-
- titleFontSize := int32(48)
- titleScale := float32(titleFontSize) / float32(baseFontSize)
- contentFontSize := int32(32)
- contentScale := float32(contentFontSize) / float32(baseFontSize)
- promptFontSize := int32(20)
- promptScale := float32(promptFontSize) / float32(baseFontSize)
-
- margin := int32(40)
- lineSpacing := int32(4)
- paragraphSpacing := int32(16)
- p.lineHeight = contentFontSize + lineSpacing
-
- // Center a content column at ~55% of screen width, left-align text within it
- contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
- contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
-
- // Title
- titleH := float32(p.titleTextureHeight) * titleScale
- titleW := float32(p.titleTextureWidth) * titleScale
- titleX := p.Base.ScreenCenterX - int32(titleW/2)
- titleY := p.Base.ScreenHeight - margin - int32(titleH)
- p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, white)
-
- // Prompt dimensions (calculate even if not shown, for content bounds)
- promptH := float32(p.promptTextureHeight) * promptScale
- promptW := float32(p.promptTextureWidth) * promptScale
- promptX := p.Base.ScreenCenterX - int32(promptW/2)
- promptY := margin
-
- // Content area bounds
- contentTop := titleY - margin
- contentBottom := promptY + int32(promptH) + margin
- p.visibleHeight = contentTop - contentBottom
-
- // Calculate total content height (accounting for paragraph breaks)
- totalContentHeight := int32(0)
- for _, line := range p.lines {
- if strings.TrimSpace(line) == "" {
- totalContentHeight += paragraphSpacing
- } else {
- totalContentHeight += p.lineHeight
- }
- }
- p.maxScrollOffset = totalContentHeight - p.visibleHeight
- if p.maxScrollOffset < 0 {
- p.maxScrollOffset = 0
- }
-
- // Render visible lines
- currentY := contentTop - p.lineHeight + p.scrollOffset
-
- for i, line := range p.lines {
- if strings.TrimSpace(line) == "" {
- currentY -= paragraphSpacing
- continue
- }
-
- if p.lineTextureIDs[i] == 0 {
- currentY -= p.lineHeight
- continue
- }
-
- // Skip lines outside visible area
- if currentY > contentTop || currentY+p.lineHeight < contentBottom {
- currentY -= p.lineHeight
- continue
- }
-
- lineW := float32(p.lineTextureWidths[i]) * contentScale
- lineH := float32(p.lineTextureHeights[i]) * contentScale
-
- p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
- currentY -= p.lineHeight
- }
-
- // Show accept/decline prompt when scrolled to bottom, scroll hint otherwise
- if p.scrollOffset >= p.maxScrollOffset {
- p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey)
- } else {
- hintScale := promptScale
- hintW := float32(p.scrollHintTextureWidth) * hintScale
- hintH := float32(p.scrollHintTextureHeight) * hintScale
- hintX := p.Base.ScreenCenterX - int32(hintW/2)
- hintY := promptY
- p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey)
- }
-
- return nil
-}
-
-func (p *TOSPage) Destroy() error {
- gl.DeleteTextures(1, &p.titleTextureID)
- gl.DeleteTextures(1, &p.promptTextureID)
- gl.DeleteTextures(1, &p.scrollHintTextureID)
-
- for _, texID := range p.lineTextureIDs {
- if texID != 0 {
- gl.DeleteTextures(1, &texID)
- }
- }
-
- return p.Base.Destroy()
-}
diff --git a/internal/page/updateCheckPage.go b/internal/page/updateCheckPage.go
index da5c1d3..d76427b 100644
--- a/internal/page/updateCheckPage.go
+++ b/internal/page/updateCheckPage.go
@@ -3,13 +3,11 @@ package page
import (
"fmt"
"os/exec"
- "strconv"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
@@ -43,8 +41,9 @@ type UpdateCheckPage struct {
dotTimer float32
}
-func (p *UpdateCheckPage) Init(app ApplicationInterface) error {
- if err := p.Base.Init(app); err != nil {
+func (p *UpdateCheckPage) Init(ctx *PageCtx) error {
+ var err error
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
@@ -62,14 +61,7 @@ func (p *UpdateCheckPage) Init(app ApplicationInterface) error {
}
// Get scale
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.Atoi(baseFontSizeStr)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := ctx.CfgPtr.InitialFontSize
scale := float32(48) / float32(baseFontSize)
// Create checking textures with animated dots
@@ -149,7 +141,7 @@ func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error {
}
switch e.Keysym.Sym {
case sdl.K_F3, sdl.K_BACKSPACE:
- p.App.UnwindToPage(&Settings{})
+ p.Context.NavigateTo(SETTINGS)
}
}
return nil
diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go
index 435c07e..9948cb1 100644
--- a/internal/page/wifiSetupPage.go
+++ b/internal/page/wifiSetupPage.go
@@ -2,7 +2,6 @@ package page
import (
"fmt"
- "strconv"
"strings"
"time"
@@ -11,7 +10,6 @@ import (
"github.com/godbus/dbus/v5"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
- "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/keybinds"
"rhemawavetech.com/rhemawave/internal/logger"
@@ -69,17 +67,14 @@ type WiFiSetupPage struct {
}
// Init sets up the page, initializes the network manager, and starts a scan.
-func (p *WiFiSetupPage) Init(app ApplicationInterface) error {
+func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
- if err := p.Base.Init(app); err != nil {
+ if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err)
}
// Get application font from database
- applicationFontName, err := db.GetConfigValue("application_font")
- if err != nil {
- return fmt.Errorf("retrieving application_font from db: %w", err)
- }
+ applicationFontName := "Roboto-Regular"
fm := fontManager.Get()
font, ok := fm.GetFont(applicationFontName)
@@ -629,14 +624,7 @@ func (p *WiFiSetupPage) updatePasswordErrorTexture() {
// Render draws the entire page.
func (p *WiFiSetupPage) Render() error {
// Get base font size from database
- baseFontSizeStr, err := db.GetConfigValue("init_font_size")
- if err != nil {
- return fmt.Errorf("retrieving init_font_size from db: %w", err)
- }
- baseFontSize, err := strconv.ParseFloat(baseFontSizeStr, 32)
- if err != nil {
- return fmt.Errorf("parsing init_font_size: %w", err)
- }
+ baseFontSize := p.Context.CfgPtr.StandardFontSize
// Colors & Config
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
diff --git a/internal/shaderManager/shaderManager.go b/internal/shaderManager/shaderManager.go
index bd95b92..f4b6396 100644
--- a/internal/shaderManager/shaderManager.go
+++ b/internal/shaderManager/shaderManager.go
@@ -8,7 +8,6 @@ import (
"sync"
"github.com/go-gl/gl/v3.3-core/gl"
- "rhemawavetech.com/rhemawave/internal/db"
)
// Shader represents a compiled GLSL program and its associated data.
@@ -35,14 +34,14 @@ var (
)
// InitShaderManager initializes the ShaderManager singleton.
-func InitShaderManager() error {
+func InitShaderManager(shaderDir string) error {
var err error
once.Do(func() {
sm := &ShaderManager{
shaders: make(map[string]*Shader),
}
- if e := sm.LoadShaders(); e != nil {
- err = fmt.Errorf("could not load shaders: %w", e)
+ if e := sm.LoadShaders(shaderDir); e != nil {
+ err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, e)
return
}
instance = sm
@@ -78,17 +77,11 @@ func (sm *ShaderManager) Close() {
}
// LoadShaders scans a directory for .vert and .frag files and creates a shader program for each pair.
-func (sm *ShaderManager) LoadShaders() error {
+func (sm *ShaderManager) LoadShaders(shaderDir string) error {
// Get assets directory from database
- assetsDir, err := db.GetConfigValue("assets_dir")
- if err != nil {
- return fmt.Errorf("retrieving assets_dir from db: %w", err)
- }
-
- srcDir := filepath.Join(assetsDir, "shaders")
shaderFiles := make(map[string]string)
- err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ err := filepath.Walk(shaderDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}