Update Database

Database is more usable now.
All pages have been updated to use the database, and share an application configuration.
Page Stack has been removed, no more pages hanging around in memory. Replaced with a 'Router' which uses an Enum to determine which page should be displayed, destroys the old page and initializes the new page all in one go. No more push, pop, rollback, etc. No more holding instances of pages.
Logger has been updated with proper timestamps and sorting now works as expected."
This commit is contained in:
NeilClack
2026-03-23 15:02:07 -05:00
parent f8fbfb721d
commit 17c088cc2f
28 changed files with 537 additions and 2276 deletions
-7
View File
@@ -11,12 +11,5 @@
"mode": "debug", "mode": "debug",
"program": "rhemawave.go" "program": "rhemawave.go"
}, },
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
] ]
} }
-45
View File
@@ -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)
-201
View File
@@ -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/<pagename>.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("<name>")`
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
+7
View File
@@ -42,6 +42,13 @@ Stored in `~/.local/share/rhemawave/`:
- `log.txt` — application log - `log.txt` — application log
- `license_key` — license key file - `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 ## Deployment
Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs. Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs.
+104 -407
View File
@@ -1,9 +1,7 @@
package application package application
import ( import (
"fmt" "errors"
"io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
@@ -12,6 +10,7 @@ import (
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"github.com/veandco/go-sdl2/ttf" "github.com/veandco/go-sdl2/ttf"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/graphics" "rhemawavetech.com/rhemawave/internal/graphics"
@@ -19,7 +18,6 @@ import (
"rhemawavetech.com/rhemawave/internal/license" "rhemawavetech.com/rhemawave/internal/license"
"rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer" "rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/network"
"rhemawavetech.com/rhemawave/internal/page" "rhemawavetech.com/rhemawave/internal/page"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -40,6 +38,9 @@ type Application struct {
pageStack []page.Page pageStack []page.Page
pendingAction func() pendingAction func()
running bool running bool
DB *gorm.DB
Config atomic.Pointer[db.AppConfig]
PageRouter *page.Router
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -47,174 +48,111 @@ type Application struct {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
func Run() { 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 { 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.Info("Constructing DataDir Path")
logger.AddFile(filepath.Join(appDataDir, "log.txt")) dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave")
if err := os.MkdirAll(dataDir, 0755); err != nil {
if err := initializeDatabase(appDataDir); err != nil { logger.Error("unable to create data directory %s %v", dataDir, err)
logger.Fatal("Database initialization failed: %v", err) return
} }
app := &Application{} testFile := filepath.Join(dataDir, ".access_test")
app.initializeSDLAndOpenGL()
defer app.cleanup()
app.initializeManagers()
app.initializeAudio()
app.validateLicenseKey()
app.startupPageStack()
app.runEventLoop()
}
// ----------------------------------------------------------------------------
// Initialization Functions
// ----------------------------------------------------------------------------
func initializeApplicationDirectory() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
appDataDir := filepath.Join(homeDir, ".local", "share", "rhemawave")
if err := os.MkdirAll(appDataDir, 0755); err != nil {
return "", fmt.Errorf("could not create application data directory %s: %w", appDataDir, err)
}
testFile := filepath.Join(appDataDir, ".access_test")
f, err := os.Create(testFile) f, err := os.Create(testFile)
if err != nil { 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() f.Close()
os.Remove(testFile) os.Remove(testFile)
return appDataDir, nil logger.AddFile(filepath.Join(dataDir, "log.txt"))
}
func initializeDatabase(appDataDir string) error { logger.Info("Connecting to database")
dbPath := filepath.Join(appDataDir, "data.db")
if err := db.InitDatabase(dbPath); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
logger.AddDatabase(db.DB)
return nil
}
type progressWriter struct { DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
w io.Writer
written *atomic.Int64
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.w.Write(p)
pw.written.Add(int64(n))
return n, err
}
// ensureAudioFile checks whether the selected audio file exists. If it does,
// it returns nil. Otherwise it starts a background download and returns a
// DownloadProgressPage that tracks the download so it can be pushed onto the
// page stack.
func ensureAudioFile() *page.DownloadProgressPage {
assetsDir, err := db.GetConfigValue("assets_dir")
if err != nil { if err != nil {
logger.Error("Failed to get assets_dir: %v", err) logger.Error("application failed to connect to the database: %v", err)
return nil return
}
selectedAudio, err := db.GetConfigValue("selected_audio")
if err != nil || selectedAudio == "" {
logger.Warning("No selected_audio configured")
return nil
} }
audioPath := filepath.Join(assetsDir, selectedAudio) if err = db.Migrate(DB); err != nil {
if _, err := os.Stat(audioPath); err == nil { logger.Error("application failed to perform database migrations: %v", err)
logger.Info("Audio file found: %s", audioPath) return
return nil
} }
logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL) // Populate the initial Config cache OR create defaults
result = DB.First(&initialConfig)
totalBytes := &atomic.Int64{} if result.Error != nil {
bytesWritten := &atomic.Int64{} if errors.Is(result.Error, gorm.ErrRecordNotFound) {
done := &atomic.Bool{} logger.Info("First run detected: Creating default configuration...")
hasError := &atomic.Bool{} initialConfig = db.AppConfig{
DataDir: dataDir,
downloadPage := &page.DownloadProgressPage{ AssetsDir: dataDir,
TotalBytes: totalBytes, SystemLevel: "BASIC",
BytesWritten: bytesWritten, SelectedAudioDevice: "default",
Done: done, InitialFontSize: 128,
HasError: hasError, 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() { logger.AddDatabase(DB)
defer done.Store(true)
if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil { app := &Application{
logger.Error("Failed to create audio directory: %v", err) DB: DB,
hasError.Store(true) }
return
}
resp, err := http.Get(audioFileURL) app.refreshCache()
if err != nil {
logger.Error("Failed to download audio file: %v", err)
hasError.Store(true)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { w, h := app.initializeSDLAndOpenGL()
logger.Error("Audio download returned status %d", resp.StatusCode) defer app.cleanup()
hasError.Store(true)
return
}
if resp.ContentLength > 0 { app.initializeManagers()
totalBytes.Store(resp.ContentLength) app.initializeAudio()
} app.PageRouter = page.NewRouter(page.PageCtx{
CfgPtr: app.Config.Load(),
tmp := audioPath + ".tmp" DbPtr: app.DB,
f, err := os.Create(tmp) W: w,
if err != nil { H: h,
logger.Error("Failed to create audio file: %v", err) RefreshConfig: app.refreshCache,
hasError.Store(true) })
return app.runEventLoop()
}
pw := &progressWriter{w: f, written: bytesWritten}
_, err = io.Copy(pw, resp.Body)
f.Close()
if err != nil {
os.Remove(tmp)
logger.Error("Failed to write audio file: %v", err)
hasError.Store(true)
return
}
if err := os.Rename(tmp, audioPath); err != nil {
os.Remove(tmp)
logger.Error("Failed to move audio file into place: %v", err)
hasError.Store(true)
return
}
logger.Info("Audio file downloaded successfully (%d bytes)", bytesWritten.Load())
}()
return downloadPage
} }
func (app *Application) initializeSDLAndOpenGL() { 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") logger.Info("Initializing SDL")
if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil { if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil {
logger.Fatal("SDL init failed: %v", err) logger.Fatal("SDL init failed: %v", err)
@@ -283,42 +221,40 @@ func (app *Application) initializeSDLAndOpenGL() {
app.initKeybinds() app.initKeybinds()
logger.Info("SDL and OpenGL initialization complete") logger.Info("SDL and OpenGL initialization complete")
return w, h
} }
func (app *Application) initializeManagers() { func (app *Application) initializeManagers() {
logger.Info("Initializing ShaderManager") 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.Fatal("ShaderManager initialization failed: %v", err)
} }
logger.Info("Initializing FontManager") 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) logger.Fatal("FontManager initialization failed: %v", err)
} }
} }
func (app *Application) initializeAudio() { func (app *Application) initializeAudio() {
logger.Info("Initializing audio") 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 { if err := mixer.Init(app.Config.Load(), app.refreshCache); err != nil {
logger.Error("Unable to initialize audio device '%s': %v", audioDevice, err) 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") logger.Error("Audio will be unavailable until a device is configured")
} else { } else {
logger.Info("Audio initialized with device: '%s'", audioDevice) logger.Info("Audio initialized with device: '%s'", app.Config.Load().SelectedAudioDevice)
} }
} }
func (app *Application) cleanup() { func (app *Application) cleanup() {
logger.Info("Cleaning up") logger.Info("Cleaning up")
for _, p := range app.pageStack { if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
p.Destroy() app.PageRouter.ActivePage.Destroy()
} }
app.pageStack = nil
graphics.DestroyVirtualFramebuffer() graphics.DestroyVirtualFramebuffer()
@@ -337,8 +273,8 @@ func (app *Application) cleanup() {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
func (app *Application) runEventLoop() { func (app *Application) runEventLoop() {
if len(app.pageStack) == 0 { if app.PageRouter == nil || app.PageRouter.ActivePage == nil {
logger.Fatal("No initial page loaded, pageStack is empty") logger.Fatal("No initial page loaded, Router is empty")
} }
const frameDelay = time.Second / targetFPS const frameDelay = time.Second / targetFPS
@@ -360,7 +296,6 @@ func (app *Application) runEventLoop() {
app.updateCurrentPage(deltaTime) app.updateCurrentPage(deltaTime)
app.renderFrame() app.renderFrame()
app.applyPendingAction()
frameTime := time.Since(frameStart) frameTime := time.Since(frameStart)
if frameTime < frameDelay { 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 { if err := currentPage.HandleEvent(&event); err != nil {
logger.Error("Page event handler error: %v", err) logger.Error("Page event handler error: %v", err)
} }
@@ -397,7 +332,7 @@ func (app *Application) handleEvents() {
} }
func (app *Application) updateCurrentPage(deltaTime float32) { func (app *Application) updateCurrentPage(deltaTime float32) {
if currentPage := app.currentPage(); currentPage != nil { if currentPage := app.PageRouter.ActivePage; currentPage != nil {
currentPage.Update(deltaTime) currentPage.Update(deltaTime)
} }
} }
@@ -407,7 +342,7 @@ func (app *Application) renderFrame() {
gl.ClearColor(0.0, 0.0, 0.0, 1.0) gl.ClearColor(0.0, 0.0, 0.0, 1.0)
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) 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() currentPage.Render()
} }
@@ -421,21 +356,16 @@ func (app *Application) renderFrame() {
func (app *Application) initKeybinds() { func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { 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() { keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
downloadPage := ensureAudioFile() app.PageRouter.NavigateTo(page.SCROLLER)
if downloadPage != nil {
downloadPage.NextPage = &page.ScrollerPage{}
app.PushPage(downloadPage)
} else {
app.PushPage(&page.ScrollerPage{})
}
}) })
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { 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() { 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() { 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() { 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 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() { func (app *Application) Quit() {
logger.Info("User has quit the application") logger.Info("User has quit the application")
app.running = false app.running = false
-492
View File
@@ -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", &sectionNum); err != nil {
logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
continue
}
title := strings.TrimSpace(parts[1])
content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
if err != nil {
logger.Error("Error reading TOS file %s: %v", file.Name(), err)
continue
}
section := TOSSection{
SectionNumber: sectionNum,
Title: title,
Content: string(content),
}
if err := DB.Create(&section).Error; err != nil {
return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
}
seeded++
}
logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
return nil
}
// GetTOSSections returns all TOS sections ordered by section number.
func GetTOSSections() ([]TOSSection, error) {
var sections []TOSSection
result := DB.Order("section_number asc").Find(&sections)
if result.Error != nil {
return nil, result.Error
}
return sections, nil
}
// GetLogs retrieves log entries from the database, ordered by creation time ascending.
// If limit > 0, at most that many entries are returned.
func GetLogs(limit int) ([]Log, error) {
var logs []Log
query := DB.Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
return logs, query.Find(&logs).Error
}
// ensureDefaultsExist checks the current configs table rows and inserts any new rows
// from defaultConfigValues to the database if they don't exist.
// This is essentially an automatic migration for new config values.
func ensureDefaultsExist() error {
for key, value := range defaultConfigValues {
var config Config
result := DB.Where("key = ?", key).First(&config)
// Always re-resolve path-based config values from the current binary location
switch key {
case "assets_dir":
value = resolveAssetsDir()
case "log_dir":
value = filepath.Join(resolveAssetsDir(), "logs.log")
}
if result.Error == gorm.ErrRecordNotFound {
// Key doesn't exist, create it with default value
if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
return fmt.Errorf("failed to create default config for %s: %w", key, err)
}
log.Printf("Added missing config key: %s", key)
} else if result.Error != nil {
return result.Error
} else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
// Update stale path-based values (e.g. after Nix store update)
config.Value = value
if err := DB.Save(&config).Error; err != nil {
return fmt.Errorf("failed to update config for %s: %w", key, err)
}
log.Printf("Updated stale config key: %s", key)
} else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
// Migrate renamed audio file
config.Value = value
if err := DB.Save(&config).Error; err != nil {
return fmt.Errorf("failed to migrate config for %s: %w", key, err)
}
log.Printf("Migrated config key: %s", key)
}
}
return nil
}
func AudioEnabled() bool {
on, err := GetConfigValue("audio_enabled")
if err != nil {
return false
}
return on == "true"
}
// resolveAssetsDir returns the assets directory. 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")
}
+48
View File
@@ -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
}
+39
View File
@@ -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
}
+5 -15
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
@@ -35,7 +34,7 @@ var (
// InitFontManager initializes the FontManager singleton. // InitFontManager initializes the FontManager singleton.
// It must be called once at startup after ShaderManager has been initialized. // It must be called once at startup after ShaderManager has been initialized.
func InitFontManager() error { func InitFontManager(cfg *db.AppConfig) error {
fontManagerOnce.Do(func() { fontManagerOnce.Do(func() {
// Dependency check: Ensure ShaderManager is ready // Dependency check: Ensure ShaderManager is ready
@@ -49,13 +48,8 @@ func InitFontManager() error {
Fonts: make(map[string]*FontEntry), Fonts: make(map[string]*FontEntry),
} }
assetsDir, err := db.GetConfigValue("assets_dir") fontPath := filepath.Join(cfg.AssetsDir, "fonts")
if err != nil { if err := fm.loadFonts(fontPath, cfg); err != nil {
initErr = fmt.Errorf("could not get assets_dir from config: %w", err)
return
}
fontPath := filepath.Join(assetsDir, "fonts")
if err := fm.loadFonts(fontPath); err != nil {
initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err) initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err)
return 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. // 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) files, err := os.ReadDir(fontDir)
if err != nil { if err != nil {
return fmt.Errorf("could not read font directory '%s': %w", fontDir, err) 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() fileName := file.Name()
if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") { if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") {
fontPath := filepath.Join(fontDir, fileName) fontPath := filepath.Join(fontDir, fileName)
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := cfg.StandardFontSize
if err != nil {
return fmt.Errorf("could not get standard_font_size from config: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil { if err != nil {
return fmt.Errorf("could not convert init_font_size to int: %w", err) return fmt.Errorf("could not convert init_font_size to int: %w", err)
} }
+22 -59
View File
@@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
) )
type Level int type Level int
@@ -24,78 +24,34 @@ func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l] return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
} }
type logEntry struct {
timestamp string
level string
message string
}
var ( var (
mu sync.Mutex DB *gorm.DB
db *gorm.DB
file *os.File file *os.File
console = log.New(os.Stderr, "", 0) 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 // AddFile attaches or replaces the file backend
func AddFile(path string) error { func AddFile(path string) error {
mu.Lock()
defer mu.Unlock()
if file != nil { if file != nil {
file.Close() 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 { if err != nil {
return err return err
} }
file = f
return nil return nil
} }
// AddDatabase attaches or replaces the database backend // AddDatabase attaches or replaces the database backend
func AddDatabase(d *gorm.DB) { func AddDatabase(d *gorm.DB) {
mu.Lock() console.Println("Adding database to logger")
defer mu.Unlock() DB = d
db = d
dbChanOnce.Do(startDBWriter)
} }
// Close shuts down the database writer goroutine and closes the file backend. // Close shuts down the file backend.
func Close() error { 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 { if file != nil {
err := file.Close() err := file.Close()
file = nil file = nil
@@ -106,24 +62,31 @@ func Close() error {
func logMsg(level Level, format string, args ...any) { func logMsg(level Level, format string, args ...any) {
message := fmt.Sprintf(format, args...) 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) line := fmt.Sprintf("%s [%s] %s", timestamp, level, message)
mu.Lock() // 1. The standard log package is internally thread-safe
console.Println(line) console.Println(line)
// 2. Write to file
if file != nil { if file != nil {
fmt.Fprintln(file, line) fmt.Fprintln(file, line)
} }
if db != nil && dbChan != nil { // both cleared by Close() // 3. Write to DB using GORM. It handles its own connection pooling and locking.
select { if DB != nil {
case dbChan <- logEntry{timestamp: timestamp, level: level.String(), message: message}: var entry db.Log
default: entry.Timestamp = now.UnixMilli()
// Channel full, drop the DB write rather than blocking 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...) } func Debug(format string, args ...any) { logMsg(LevelDebug, format, args...) }
+23 -30
View File
@@ -4,7 +4,6 @@ package mixer
import ( import (
"errors" "errors"
"fmt" "fmt"
"strconv"
"sync" "sync"
"github.com/veandco/go-sdl2/mix" "github.com/veandco/go-sdl2/mix"
@@ -30,16 +29,20 @@ var (
defaultChannels = 2 defaultChannels = 2
defaultChunkSize = 1024 defaultChunkSize = 1024
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
refreshCacheCallback func()
) )
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times. // 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() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if inited { if inited {
return nil return nil
} }
refreshCacheCallback = onUpdate
// Let SDL choose the best audio driver for the system // Let SDL choose the best audio driver for the system
// Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc. // Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc.
// Users can set SDL_AUDIODRIVER environment variable if needed // 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. // 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. // Clean up the subsystem on failure so callers can retry later.
sdl.QuitSubSystem(sdl.INIT_AUDIO) sdl.QuitSubSystem(sdl.INIT_AUDIO)
return err return err
} }
// Check if the volume is stored in the config; if not, default to max. // TODO: Check if this logic is still needed; move to application
storedVolume, err := db.GetConfigValue("last_volume") // if lastVolume < 0 {
if err != nil { // lastVolume = int(mix.MAX_VOLUME)
return fmt.Errorf("retrieving last_volume from config: %w", err) // currentVolume = lastVolume
} // if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
// }
lastVolume, err := strconv.Atoi(storedVolume) // } else {
if err != nil { // currentVolume = lastVolume
return fmt.Errorf("parsing last_volume %q: %w", storedVolume, err) // }
}
if lastVolume < 0 {
lastVolume = int(mix.MAX_VOLUME)
currentVolume = lastVolume
if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
}
} else {
currentVolume = lastVolume
}
// mix.VolumeMusic(currentVolume) // 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. // 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. // If something is already playing, it stops and replaces it.
func Play(path string, loopForever bool) error { func Play(path string, loopForever bool, cfg *db.AppConfig) error {
if !db.AudioEnabled() { if !cfg.AudioEnabled {
return nil 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. // 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). // It also frees the currently loaded music (so a new Play will reload).
func Stop(fadeMs int) { func Stop(fadeMs int, cfg *db.AppConfig) {
if !db.AudioEnabled() { if !cfg.AudioEnabled {
return return
} }
@@ -184,8 +177,8 @@ func Stop(fadeMs int) {
} }
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale. // SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
func SetVolume128(increment int) { func SetVolume128(increment int, cfg *db.AppConfig) {
if !db.AudioEnabled() { if !cfg.AudioEnabled {
return return
} }
@@ -201,8 +194,8 @@ func SetVolume128(increment int) {
} }
// Store in config // Store in config
if err := db.SetConfigValue("last_volume", strconv.Itoa(currentVolume)); err != nil { cfg.LastVolume = currentVolume
} refreshCacheCallback()
// Apply to SDL_mixer // Apply to SDL_mixer
if inited && deviceOpen { if inited && deviceOpen {
+26
View File
@@ -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
}
+19
View File
@@ -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)
}
+19 -46
View File
@@ -2,10 +2,10 @@ package page
import ( import (
"fmt" "fmt"
"strconv"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
@@ -24,9 +24,11 @@ type AudioDevices struct {
highlightIndex int highlightIndex int
selectedIndex int selectedIndex int
verticalMargin int32 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 var err error
p.verticalMargin = int32(20) p.verticalMargin = int32(20)
@@ -50,19 +52,12 @@ func (p *AudioDevices) Init(app ApplicationInterface) error {
} }
// Initialize base // 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) return fmt.Errorf("base init failed: %w", err)
} }
// Get font size from database // Get font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
}
// Calculate scales // Calculate scales
titleScale := float32(64) / float32(baseFontSize) titleScale := float32(64) / float32(baseFontSize)
@@ -90,10 +85,7 @@ func (p *AudioDevices) Init(app ApplicationInterface) error {
} }
// Get currently selected device from database // Get currently selected device from database
currentDevice, err := db.GetConfigValue("audio_device_name") currentDevice := p.Base.Context.CfgPtr.SelectedAudioDevice
if err != nil {
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
}
// Create UI items for each device // Create UI items for each device
for i, name := range devices { // Iterate over the devices 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") on := p.Base.Context.CfgPtr.AudioEnabled
if err != nil {
return fmt.Errorf("unable to query database for audio_enabled: %w", err)
}
audioStatus := "Turn Audio On" audioStatus := "Turn Audio On"
if on == "true" { if on {
audioStatus = "Turn Audio Off" audioStatus = "Turn Audio Off"
} }
@@ -177,22 +166,11 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
// Handle audio disable // Handle audio disable
if selection == "Turn Audio Off" || selection == "Turn Audio On" { if selection == "Turn Audio Off" || selection == "Turn Audio On" {
on, err := db.GetConfigValue("audio_enabled") newStatus := !p.Base.Context.CfgPtr.AudioEnabled
if err != nil { updatedConfig := p.Base.Context.CfgPtr
return fmt.Errorf("failed to fetch audio_enabled from database: %w", err) updatedConfig.AudioEnabled = newStatus
} p.Base.Context.DbPtr.Save(&updatedConfig)
p.Base.Context.RefreshConfig()
currentStatus, err := strconv.ParseBool(on)
if err != nil {
return fmt.Errorf("failed to parse audio_enabled value: %w", err)
}
newStatus := strconv.FormatBool(!currentStatus)
if err := db.SetConfigValue("audio_enabled", newStatus); err != nil {
return fmt.Errorf("failed to update audio_enabled: %w", err)
}
p.App.UnwindToPage(&Settings{})
return nil return nil
} }
@@ -208,17 +186,12 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
} }
// Save settings // Save settings
if err := db.SetConfigValue("audio_device_name", deviceName); err != nil { p.Base.Context.CfgPtr.SelectedAudioDevice = deviceName
return fmt.Errorf("failed to save audio device: %w", err) p.Base.Context.CfgPtr.AudioEnabled = true
} p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
if err := db.SetConfigValue("audio_enabled", "true"); err != nil { p.Base.Context.RefreshConfig()
return fmt.Errorf("failed to enable audio: %w", err)
}
p.App.UnwindToPage(&Settings{})
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
p.App.UnwindToPage(&Settings{}) p.Base.Context.NavigateTo(SETTINGS)
} }
return nil return nil
-276
View File
@@ -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
}
+12 -32
View File
@@ -3,13 +3,11 @@ package page
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -53,19 +51,15 @@ func isValidEmail(email string) bool {
} }
// Init performs basic page initialization and creation of static assets. // 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) return fmt.Errorf("failed base page initialization: %w", err)
} }
p.title = "Your Email Address" p.title = "Your Email Address"
// Get application font from database applicationFont := "Roboto-Regular"
applicationFont, err := db.GetConfigValue("application_font")
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text") textShader, ok := shaderManager.Get().Get("text")
@@ -87,10 +81,7 @@ func (p *EmailAddressPage) Init(app ApplicationInterface) error {
p.titleTextureHeight = titleHeight p.titleTextureHeight = titleHeight
// Load existing email address from database // Load existing email address from database
emailAddress, err := db.GetConfigValue("email_address") emailAddress := ctx.CfgPtr.EmailAddress
if err != nil {
return fmt.Errorf("retrieving email_address from db: %w", err)
}
if emailAddress != "" { if emailAddress != "" {
p.inputText = 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 // Allow escape back to settings if coming from settings
sdl.StopTextInput() sdl.StopTextInput()
p.App.UnwindToPage(&Settings{}) p.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN: case sdl.K_RETURN:
// Validate email - check empty first, then format // Validate email - check empty first, then format
if p.inputText == "" { if p.inputText == "" {
@@ -163,16 +154,15 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
sdl.StopTextInput() sdl.StopTextInput()
// Save email address to database // Save email address to database
if err := db.SetConfigValue("email_address", p.inputText); err != nil { p.Base.Context.CfgPtr.EmailAddress = p.inputText
return fmt.Errorf("failed to save email address: %w", err) p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
} p.Base.Context.RefreshConfig()
if p.isFirstTimeSetup { if p.isFirstTimeSetup {
// First-time setup: navigate to License Key page // First-time setup: navigate to License Key page
p.App.PushPage(&LicenseKeyPage{}) p.Context.NavigateTo(LICENSE_KEY)
} else { } else {
// Coming from settings: rewind back to settings p.Context.NavigateTo(SETTINGS)
p.App.UnwindToPage(&Settings{})
} }
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
if len(p.inputText) > 0 { 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. // 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 { func (p *EmailAddressPage) Update(dt float32) error {
// Get application font from database // Get application font from database
applicationFont, err := db.GetConfigValue("application_font") applicationFont := "Roboto-Regular"
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
sm := shaderManager.Get() sm := shaderManager.Get()
@@ -266,14 +253,7 @@ func (p *EmailAddressPage) Update(dt float32) error {
// Render performs page drawing logic. // Render performs page drawing logic.
func (p *EmailAddressPage) Render() error { func (p *EmailAddressPage) Render() error {
// Get base font size from database // Get base font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
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() sm := shaderManager.Get()
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -12,14 +11,13 @@ import (
"github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
var GitVersion = "dev" var GitVersion = "dev"
type Welcome struct { type Home struct {
Base Base
title StringItem title StringItem
version StringItem version StringItem
@@ -70,7 +68,8 @@ func countSDLDisplays() int {
} }
// updateDisplayCounts updates the display count textures // updateDisplayCounts updates the display count textures
func (p *Welcome) updateDisplayCounts() error { func (p *Home) updateDisplayCounts() error {
var err error
// Get font // Get font
fm := fontManager.Get() fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular") font, ok := fm.GetFont("Roboto-Regular")
@@ -79,14 +78,7 @@ func (p *Welcome) updateDisplayCounts() error {
} }
// Get base font size // Get base font size
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
// Update DRM count // Update DRM count
p.drmDisplayCount = countConnectedDisplays() p.drmDisplayCount = countConnectedDisplays()
@@ -127,14 +119,14 @@ func (p *Welcome) updateDisplayCounts() error {
} }
// Init performs basic page initialization and creation of static assets. // 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" title := "Rhema Wave"
version := GitVersion version := GitVersion
prompt := "Press F2 to Start, or F3 for Settings" prompt := "Press F2 to Start, or F3 for Settings"
note := "You can always press F1 to return here" 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) 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) 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 // Create license info textures if data exists
if emailAddress != "" || licenseKey != "" { if p.Base.Context.CfgPtr.EmailAddress != "" || p.Base.Context.CfgPtr.LicenseKey != "" {
p.hasLicenseInfo = true p.hasLicenseInfo = true
// Create heading // Create heading
@@ -199,8 +180,8 @@ func (p *Welcome) Init(app ApplicationInterface) error {
} }
// Create email display if exists // Create email display if exists
if emailAddress != "" { if p.Base.Context.CfgPtr.EmailAddress != "" {
emailText := fmt.Sprintf("Email: %s", emailAddress) emailText := fmt.Sprintf("Email: %s", p.Base.Context.CfgPtr.EmailAddress)
p.emailDisplay, err = NewStringItem(emailText, font, colors.White) p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
if err != nil { if err != nil {
return fmt.Errorf("unable to create email display texture: %w", err) 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 // Create license key display if exists
if licenseKey != "" { if p.Base.Context.CfgPtr.LicenseKey != "" {
licenseKeyText := fmt.Sprintf("License Key: %s", licenseKey) licenseKeyText := fmt.Sprintf("License Key: %s", p.Base.Context.CfgPtr.LicenseKey)
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White) p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
if err != nil { if err != nil {
return fmt.Errorf("unable to create license key display texture: %w", err) 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 // Get base font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
p.baseFontSize = baseFontSize p.baseFontSize = baseFontSize
@@ -249,13 +223,13 @@ func (p *Welcome) Init(app ApplicationInterface) error {
return nil return nil
} }
// HandleEvent processes SDL events and handles them appropriately for the Welcome page. // HandleEvent processes SDL events and handles them appropriately for the Home page.
func (p *Welcome) HandleEvent(event *sdl.Event) error { func (p *Home) HandleEvent(event *sdl.Event) error {
return nil return nil
} }
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. // 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 // Update display counts every 2 seconds
if time.Since(p.lastDisplayUpdate) >= 2*time.Second { if time.Since(p.lastDisplayUpdate) >= 2*time.Second {
if err := p.updateDisplayCounts(); err != nil { if err := p.updateDisplayCounts(); err != nil {
@@ -265,7 +239,7 @@ func (p *Welcome) Update(dt float32) error {
return nil return nil
} }
func (p *Welcome) Render() error { func (p *Home) Render() error {
baseFontSize := p.baseFontSize baseFontSize := p.baseFontSize
sm := shaderManager.Get() sm := shaderManager.Get()
verticalMargin := int32(20) verticalMargin := int32(20)
@@ -417,7 +391,7 @@ func (p *Welcome) Render() error {
} }
// Destroy cleans up page-specific resources. // 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.title.ID)
gl.DeleteTextures(1, &p.version.ID) gl.DeleteTextures(1, &p.version.ID)
gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.prompt.ID)
+12 -44
View File
@@ -4,14 +4,12 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
@@ -47,34 +45,17 @@ type LicenseKeyPage struct {
textInputSupported bool 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. // 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) return fmt.Errorf("failed base page initialization: %w", err)
} }
p.title = "License Key" p.title = "License Key"
// Get application font from database // Get application font from database
applicationFont, err := db.GetConfigValue("application_font") applicationFont := "Roboto-Regular"
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text") textShader, ok := shaderManager.Get().Get("text")
@@ -96,10 +77,7 @@ func (p *LicenseKeyPage) Init(app ApplicationInterface) error {
p.titleTextureHeight = titleHeight p.titleTextureHeight = titleHeight
// Load existing license key from database // Load existing license key from database
licenseKey, err := db.GetConfigValue("license_key") licenseKey := p.Base.Context.CfgPtr.LicenseKey
if err != nil {
return fmt.Errorf("retrieving license_key from db: %w", err)
}
if licenseKey != "" { if licenseKey != "" {
p.inputText = 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 // Allow escape back to settings if coming from settings
sdl.StopTextInput() sdl.StopTextInput()
p.App.UnwindToPage(&Settings{}) p.Base.Context.NavigateTo(SETTINGS)
case sdl.K_RETURN: case sdl.K_RETURN:
// Validate license key - check empty first, then length, then characters // Validate license key - check empty first, then length, then characters
cleaned := strings.ReplaceAll(p.inputText, "-", "") cleaned := strings.ReplaceAll(p.inputText, "-", "")
@@ -210,9 +188,9 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
sdl.StopTextInput() sdl.StopTextInput()
// Save license key to database // Save license key to database
if err := db.SetConfigValue("license_key", p.inputText); err != nil { p.Base.Context.CfgPtr.LicenseKey = p.inputText
return fmt.Errorf("failed to save license key: %w", err) p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
} p.Base.Context.RefreshConfig()
// Write license key to plain text file for the updater // Write license key to plain text file for the updater
if homeDir, err := os.UserHomeDir(); err == nil { if homeDir, err := os.UserHomeDir(); err == nil {
@@ -224,10 +202,10 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
if p.isFirstTimeSetup { if p.isFirstTimeSetup {
// First-time setup: rewind to Welcome page // First-time setup: rewind to Welcome page
p.App.UnwindToPage(&Welcome{}) p.Base.Context.NavigateTo(HOME)
} else { } else {
// Coming from settings: rewind back to settings // Coming from settings: rewind back to settings
p.App.UnwindToPage(&Settings{}) p.Base.Context.NavigateTo(SETTINGS)
} }
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
if len(p.inputText) > 0 { 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. // 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 { func (p *LicenseKeyPage) Update(dt float32) error {
// Get application font from database // Get application font from database
applicationFont, err := db.GetConfigValue("application_font") applicationFont := "Roboto-Regular"
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
sm := shaderManager.Get() sm := shaderManager.Get()
@@ -328,14 +303,7 @@ func (p *LicenseKeyPage) Update(dt float32) error {
// Render performs page drawing logic. // Render performs page drawing logic.
func (p *LicenseKeyPage) Render() error { func (p *LicenseKeyPage) Render() error {
// Get base font size from database // Get base font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
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() sm := shaderManager.Get()
+21 -22
View File
@@ -2,14 +2,15 @@ package page
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"time"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -35,27 +36,32 @@ type LogViewerPage struct {
visibleHeight int32 visibleHeight int32
} }
func (p *LogViewerPage) Init(app ApplicationInterface) error { func (p *LogViewerPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(app); err != nil { var logs []db.Log
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err) return fmt.Errorf("failed base page initialization: %w", err)
} }
logs, err := db.GetLogs(1000) if result := p.Base.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
if err != nil { logger.Error("error querying database for logs: %s", result.Error)
return fmt.Errorf("failed to get logs: %w", err) return fmt.Errorf("%s", result.Error)
} }
// Format and word-wrap each log entry // 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 { for _, entry := range logs {
formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message) logTime := time.UnixMilli(entry.Timestamp)
wrapped := wrapText(formatted, 100) timeStr := logTime.Format("2006-01-02 15:04:05")
p.lines = append(p.lines, strings.Split(wrapped, "\n")...) line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
p.lines = append(p.lines, line)
} }
applicationFont, err := db.GetConfigValue("application_font") applicationFont := "Roboto-Regular"
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
textShader, ok := shaderManager.Get().Get("text") textShader, ok := shaderManager.Get().Get("text")
@@ -109,7 +115,7 @@ func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
if e.Type == sdl.KEYDOWN { if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym { switch e.Keysym.Sym {
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
p.App.SchedulePop() p.Base.Context.NavigateTo(SETTINGS)
case sdl.K_UP: case sdl.K_UP:
p.scrollOffset -= p.lineHeight p.scrollOffset -= p.lineHeight
if p.scrollOffset < 0 { if p.scrollOffset < 0 {
@@ -153,14 +159,7 @@ func (p *LogViewerPage) Update(dt float32) error {
} }
func (p *LogViewerPage) Render() error { func (p *LogViewerPage) Render() error {
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
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() sm := shaderManager.Get()
textShader, _ := sm.Get("text") textShader, _ := sm.Get("text")
+12 -24
View File
@@ -12,29 +12,16 @@ import (
// Page defines the contract for all application screens. // Page defines the contract for all application screens.
type Page interface { type Page interface {
Init(app ApplicationInterface) error Init(ctx *PageCtx) error
HandleEvent(event *sdl.Event) error HandleEvent(event *sdl.Event) error
Update(dt float32) error Update(dt float32) error
Render() error Render() error
// Destroy cleans up the page's resources. The Application's page manager
// is responsible for calling this method
Destroy() error 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. // Base is a helper struct that pages can embed to get common functionality.
type Base struct { type Base struct {
App ApplicationInterface Context *PageCtx
QuadVAO uint32 QuadVAO uint32
QuadVBO uint32 QuadVBO uint32
Projection mgl32.Mat4 Projection mgl32.Mat4
@@ -45,9 +32,8 @@ type Base struct {
} }
// Init stores the application context and sets up common resources. // Init stores the application context and sets up common resources.
func (p *Base) Init(app ApplicationInterface) error { func (p *Base) Init(ctx *PageCtx) error {
p.App = app p.Context = ctx
vertices := []float32{ vertices := []float32{
// pos(x,y) tex(u,v) // pos(x,y) tex(u,v)
0.0, 1.0, 0.0, 1.0, 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, 1.0, 0.0, 1.0, 0.0,
} }
screenWidth, screenHeight := p.App.GetDrawableSize() W := p.Context.W
p.Projection = mgl32.Ortho(0, float32(screenWidth), 0, float32(screenHeight), -1, 1) H := p.Context.H
p.ScreenHeight = screenHeight
p.ScreenWidth = screenWidth p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1)
p.ScreenCenterX = screenWidth / 2 p.ScreenHeight = H
p.ScreenCenterY = screenHeight / 2 p.ScreenWidth = W
p.ScreenCenterY = H / 2
p.ScreenCenterX = W / 2
gl.GenVertexArrays(1, &p.QuadVAO) gl.GenVertexArrays(1, &p.QuadVAO)
gl.GenBuffers(1, &p.QuadVBO) gl.GenBuffers(1, &p.QuadVBO)
+28 -32
View File
@@ -2,35 +2,35 @@ package page
import ( import (
"fmt" "fmt"
"strconv"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
type ProclamationOptions struct { type ProclamationOptions struct {
Base Base
font *fontManager.FontEntry font *fontManager.FontEntry
textShader *shaderManager.Shader textShader *shaderManager.Shader
solidShader *shaderManager.Shader solidShader *shaderManager.Shader
title StringItem title StringItem
prompt StringItem prompt StringItem
proclamations []StringItem proclamations []StringItem
proclamationIDs []uint // Database IDs corresponding to each proclamation proclamationIDs []string
activeMap map[int]bool // Maps display index to selected state activeMap map[int]bool // Maps display index to selected state
highlightIndex int highlightIndex int
verticalMargin int32 verticalMargin int32
} }
func (p *ProclamationOptions) Init(app ApplicationInterface) error { func (p *ProclamationOptions) Init(ctx *PageCtx) error {
var err error var err error
p.verticalMargin = int32(20) p.verticalMargin = int32(20)
p.activeMap = make(map[int]bool) p.activeMap = make(map[int]bool)
p.proclamationIDs = make([]uint, 0) p.proclamationIDs = make([]string, 0)
// Get font // Get font
fm := fontManager.Get() fm := fontManager.Get()
@@ -53,19 +53,12 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
} }
// Initialize base // 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) return fmt.Errorf("base init failed: %w", err)
} }
// Get font size from database // Get font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := ctx.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
}
// Calculate scales // Calculate scales
titleScale := float32(64) / float32(baseFontSize) titleScale := float32(64) / float32(baseFontSize)
@@ -86,9 +79,10 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
p.prompt.Scale(itemScale) p.prompt.Scale(itemScale)
// Load proclamations from database // Load proclamations from database
proclamations, err := db.GetProclamations() var proclamations []db.Proclamation
if err != nil { if result := p.Context.DbPtr.Find(&proclamations); result.Error != nil {
return fmt.Errorf("retrieving proclamations from db: %w", err) logger.Error("unable to query database for proclamations: %v", result.Error)
return fmt.Errorf("%s", result.Error)
} }
// Create UI items for each available proclamation // Create UI items for each available proclamation
@@ -97,7 +91,7 @@ func (p *ProclamationOptions) Init(app ApplicationInterface) error {
continue continue
} }
label, err := NewStringItem(proclamation.Title, p.font, colors.White) label, err := NewStringItem(proclamation.Name, p.font, colors.White)
if err != nil { if err != nil {
return fmt.Errorf("creating proclamation label: %w", err) return fmt.Errorf("creating proclamation label: %w", err)
} }
@@ -152,7 +146,7 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
p.highlightIndex++ p.highlightIndex++
} }
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
p.App.UnwindToPage(&Settings{}) p.Context.NavigateTo(SETTINGS)
case sdl.K_SPACE: case sdl.K_SPACE:
// Toggle selection for highlighted item // Toggle selection for highlighted item
if p.activeMap[p.highlightIndex] { if p.activeMap[p.highlightIndex] {
@@ -167,7 +161,7 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
} }
case sdl.K_RETURN: case sdl.K_RETURN:
// Just return to settings - no saving needed // Just return to settings - no saving needed
p.App.UnwindToPage(&Settings{}) p.Context.NavigateTo(SETTINGS)
} }
} }
return nil return nil
@@ -183,15 +177,17 @@ func (p *ProclamationOptions) toggleProclamation(displayIndex int) error {
isSelected := p.activeMap[displayIndex] isSelected := p.activeMap[displayIndex]
// Get the proclamation from database // Get the proclamation from database
var proclamation db.Proclamations var proclamation db.Proclamation
if err := db.DB.First(&proclamation, dbID).Error; err != nil { proclamation.ID = dbID
return fmt.Errorf("retrieving proclamation %d: %w", dbID, err) 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 // Update the Selected field
proclamation.Selected = isSelected proclamation.Selected = isSelected
if err := db.DB.Save(&proclamation).Error; err != nil { if result := p.Context.DbPtr.Save(&proclamation); result.Error != nil {
return fmt.Errorf("updating proclamation %d: %w", dbID, err) logger.Error("failed to update proclamation %s: %v", proclamation.ID, result.Error)
return fmt.Errorf("%s", result.Error)
} }
return nil return nil
+75
View File
@@ -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)
}
+13 -42
View File
@@ -60,7 +60,6 @@ import (
"fmt" "fmt"
"math" "math"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
@@ -194,7 +193,7 @@ type ScrollerPage struct {
hold float32 hold float32
} }
func (p *ScrollerPage) Init(app ApplicationInterface) error { func (p *ScrollerPage) Init(ctx *PageCtx) error {
p.increment = 100.0 p.increment = 100.0
p.velocity = 800.0 p.velocity = 800.0
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.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.font = font
p.Base.Init(app) p.Base.Init(ctx)
// Get text shader // Get text shader
textShader, ok := sm.Get("text") textShader, ok := sm.Get("text")
@@ -226,10 +225,8 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
p.wordIndex = 0 p.wordIndex = 0
// Get line pattern from database // Get line pattern from database
linePatternName, err := db.GetConfigValue("line_pattern") var err error
if err != nil { linePatternName := p.Base.Context.CfgPtr.LinePattern
return fmt.Errorf("retrieving line_pattern from db: %w", err)
}
p.activePattern, err = linePattern.Get(linePatternName) p.activePattern, err = linePattern.Get(linePatternName)
if err != nil { if err != nil {
@@ -249,23 +246,8 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
p.colorPattern = colorPattern.NewColorPattern(baseColors) p.colorPattern = colorPattern.NewColorPattern(baseColors)
// Get font sizes from database (optimize: load once, cache as int32) // Get font sizes from database (optimize: load once, cache as int32)
standardFontSizeStr, err := db.GetConfigValue("standard_font_size") standardFontSize := p.Base.Context.CfgPtr.StandardFontSize
if err != nil { baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
return fmt.Errorf("retrieving standard_font_size from db: %w", err)
}
standardFontSize, err := strconv.Atoi(standardFontSizeStr)
if err != nil {
return fmt.Errorf("parsing standard_font_size: %w", err)
}
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
// Font setup (optimized: calculate once, reuse) // Font setup (optimized: calculate once, reuse)
p.fontScale = float32(standardFontSize) / float32(baseFontSize) p.fontScale = float32(standardFontSize) / float32(baseFontSize)
@@ -302,10 +284,7 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
} }
// Audio setup // Audio setup
audioDeviceName, err := db.GetConfigValue("audio_device_name") audioDeviceName := p.Base.Context.CfgPtr.SelectedAudioDevice
if err != nil {
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
}
// Switch audio devices if needed // Switch audio devices if needed
if mixer.CurrentDevice() != audioDeviceName { if mixer.CurrentDevice() != audioDeviceName {
@@ -315,19 +294,13 @@ func (p *ScrollerPage) Init(app ApplicationInterface) error {
} }
// Start audio playback // Start audio playback
selectedAudio, err := db.GetConfigValue("selected_audio") selectedAudio := p.Base.Context.CfgPtr.SelectedAudio
if err != nil {
return fmt.Errorf("retrieving selected_audio from db: %w", err)
}
assetsDir, err := db.GetConfigValue("assets_dir") assetsDir := p.Base.Context.CfgPtr.AssetsDir
if err != nil {
return fmt.Errorf("retrieving assets_dir from db: %w", err)
}
if selectedAudio == "" { if selectedAudio == "" {
} else { } 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{} p.words = []string{}
// Get all selected proclamations from database // Get all selected proclamations from database
proclamations, err := db.GetProclamations() var proclamations []db.Proclamation
if err != nil { p.Base.Context.DbPtr.Find(&proclamations)
return fmt.Errorf("retrieving proclamations from db: %w", err)
}
// Build word list from selected, available proclamations // Build word list from selected, available proclamations
for _, proclamation := range proclamations { for _, proclamation := range proclamations {
@@ -614,7 +585,7 @@ func (p *ScrollerPage) Destroy() error {
return nil return nil
} }
mixer.Stop(1000) mixer.Stop(1000, p.Context.CfgPtr)
// GL cleanup — delete all text textures // GL cleanup — delete all text textures
for i := 0; i < p.lines.Len(); i++ { for i := 0; i < p.lines.Len(); i++ {
+21 -30
View File
@@ -2,12 +2,10 @@ package page
import ( import (
"fmt" "fmt"
"strconv"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -26,11 +24,11 @@ type Settings struct {
} }
type Option struct { type Option struct {
page Page target pageType
label StringItem label StringItem
} }
func (p *Settings) Init(app ApplicationInterface) error { func (p *Settings) Init(ctx *PageCtx) error {
var err error var err error
p.verticalMargin = int32(20) p.verticalMargin = int32(20)
@@ -54,7 +52,7 @@ func (p *Settings) Init(app ApplicationInterface) error {
} }
// Initialize base // 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) 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) return fmt.Errorf("creating proclamations label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &ProclamationOptions{}, target: PROCLAMATIONS,
label: proclamationsLabel, label: proclamationsLabel,
}) })
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White) 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) return fmt.Errorf("creating wifi label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &WiFiSetupPage{}, target: WIFI,
label: wifiLabel, label: wifiLabel,
}) })
audioLabel, err := NewStringItem("Audio Device", font, colors.White) 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) return fmt.Errorf("creating audio label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &AudioDevices{}, target: AUDIO,
label: audioLabel, label: audioLabel,
}) })
emailLabel, err := NewStringItem("Email Address", font, colors.White) 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) return fmt.Errorf("creating email label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &EmailAddressPage{}, target: EMAIL_ADDRESS,
label: emailLabel, label: emailLabel,
}) })
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White) 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) return fmt.Errorf("creating license key label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &LicenseKeyPage{}, target: LICENSE_KEY,
label: licenseKeyLabel, label: licenseKeyLabel,
}) })
logsLabel, err := NewStringItem("Logs", font, colors.White) 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) return fmt.Errorf("creating logs label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &LogViewerPage{}, target: LOGS,
label: logsLabel, label: logsLabel,
}) })
updateLabel, err := NewStringItem("Check for Updates", font, colors.White) 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) return fmt.Errorf("creating update label: %w", err)
} }
p.options = append(p.options, Option{ p.options = append(p.options, Option{
page: &UpdateCheckPage{}, target: CHECK_UPDATES,
label: updateLabel, label: updateLabel,
}) })
// Get base font size from database // Get base font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Context.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
// Calculate scales // Calculate scales
titleScale := float32(64) / float32(baseFontSize) titleScale := float32(64) / float32(baseFontSize)
@@ -208,10 +199,10 @@ func (p *Settings) HandleEvent(event *sdl.Event) error {
case sdl.K_RETURN: case sdl.K_RETURN:
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) { if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
selectedOption := p.options[p.highlightIndex] selectedOption := p.options[p.highlightIndex]
p.App.SwitchPage(selectedOption.page) p.Context.NavigateTo(selectedOption.target)
} }
case sdl.K_BACKSPACE: case sdl.K_BACKSPACE:
p.App.SwitchPage(&Welcome{}) p.Context.NavigateTo(HOME)
} }
} }
return nil return nil
-388
View File
@@ -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: <p>, <br>, <li>, <h1>-<h6>. All other tags are stripped.
// HTML entities &amp; &lt; &gt; &quot; &#39; are decoded.
func parseHTMLToLines(html string, maxCharsPerLine int) []string {
var lines []string
// Replace <br>, <br/>, <br /> with newline markers
br := strings.NewReplacer(
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
"<BR>", "\n", "<BR/>", "\n", "<BR />", "\n",
)
html = br.Replace(html)
// Process block-level tags
// Replace </p>, </h1>-</h6> with paragraph break marker
for _, tag := range []string{"</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} {
html = strings.ReplaceAll(html, tag, "\n\n")
html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
}
// Replace <li> with list prefix marker
html = strings.ReplaceAll(html, "<li>", "\n- ")
html = strings.ReplaceAll(html, "<LI>", "\n- ")
// Strip all remaining HTML tags
var stripped strings.Builder
inTag := false
for _, r := range html {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
stripped.WriteRune(r)
}
}
text := stripped.String()
// Decode HTML entities
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
// Split into segments on double-newline (paragraph breaks) and single newline (line breaks)
paragraphs := strings.Split(text, "\n\n")
for i, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
// Handle single newlines within a paragraph (from <br> tags)
sublines := strings.Split(para, "\n")
for _, sub := range sublines {
sub = strings.TrimSpace(sub)
if sub == "" {
continue
}
wrapped := wrapText(sub, maxCharsPerLine)
lines = append(lines, strings.Split(wrapped, "\n")...)
}
// Add paragraph break (empty line) between paragraphs
if i < len(paragraphs)-1 {
lines = append(lines, "")
}
}
return lines
}
func wrapText(text string, maxCharsPerLine int) string {
var result strings.Builder
words := strings.Fields(text)
lineLen := 0
for _, word := range words {
if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 {
result.WriteString("\n")
lineLen = 0
}
if lineLen > 0 {
result.WriteString(" ")
lineLen++
}
result.WriteString(word)
lineLen += len(word)
}
return result.String()
}
func (p *TOSPage) HandleEvent(event *sdl.Event) error {
actualEvent := *event
switch e := actualEvent.(type) {
case *sdl.KeyboardEvent:
if e.Type == sdl.KEYDOWN {
switch e.Keysym.Sym {
case sdl.K_n:
logger.Info("User declined TOS (%s); exiting", p.ConfigKey)
p.App.Quit()
case sdl.K_y:
if p.scrollOffset < p.maxScrollOffset {
return nil
}
logger.Info("User accepted TOS (%s)", p.ConfigKey)
if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil {
return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err)
}
p.App.SchedulePop()
case sdl.K_UP:
p.scrollOffset -= p.lineHeight
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
case sdl.K_DOWN:
p.scrollOffset += p.lineHeight
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
case sdl.K_PAGEUP:
p.scrollOffset -= p.visibleHeight
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
case sdl.K_PAGEDOWN:
p.scrollOffset += p.visibleHeight
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
case sdl.K_HOME:
p.scrollOffset = 0
case sdl.K_END:
p.scrollOffset = p.maxScrollOffset
}
}
case *sdl.MouseWheelEvent:
p.scrollOffset -= e.Y * p.lineHeight * 3
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
if p.scrollOffset > p.maxScrollOffset {
p.scrollOffset = p.maxScrollOffset
}
}
return nil
}
func (p *TOSPage) Update(dt float32) error {
return nil
}
func (p *TOSPage) Render() error {
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
sm := shaderManager.Get()
textShader, _ := sm.Get("text")
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
titleFontSize := int32(48)
titleScale := float32(titleFontSize) / float32(baseFontSize)
contentFontSize := int32(32)
contentScale := float32(contentFontSize) / float32(baseFontSize)
promptFontSize := int32(20)
promptScale := float32(promptFontSize) / float32(baseFontSize)
margin := int32(40)
lineSpacing := int32(4)
paragraphSpacing := int32(16)
p.lineHeight = contentFontSize + lineSpacing
// Center a content column at ~55% of screen width, left-align text within it
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
// Title
titleH := float32(p.titleTextureHeight) * titleScale
titleW := float32(p.titleTextureWidth) * titleScale
titleX := p.Base.ScreenCenterX - int32(titleW/2)
titleY := p.Base.ScreenHeight - margin - int32(titleH)
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, white)
// Prompt dimensions (calculate even if not shown, for content bounds)
promptH := float32(p.promptTextureHeight) * promptScale
promptW := float32(p.promptTextureWidth) * promptScale
promptX := p.Base.ScreenCenterX - int32(promptW/2)
promptY := margin
// Content area bounds
contentTop := titleY - margin
contentBottom := promptY + int32(promptH) + margin
p.visibleHeight = contentTop - contentBottom
// Calculate total content height (accounting for paragraph breaks)
totalContentHeight := int32(0)
for _, line := range p.lines {
if strings.TrimSpace(line) == "" {
totalContentHeight += paragraphSpacing
} else {
totalContentHeight += p.lineHeight
}
}
p.maxScrollOffset = totalContentHeight - p.visibleHeight
if p.maxScrollOffset < 0 {
p.maxScrollOffset = 0
}
// Render visible lines
currentY := contentTop - p.lineHeight + p.scrollOffset
for i, line := range p.lines {
if strings.TrimSpace(line) == "" {
currentY -= paragraphSpacing
continue
}
if p.lineTextureIDs[i] == 0 {
currentY -= p.lineHeight
continue
}
// Skip lines outside visible area
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
currentY -= p.lineHeight
continue
}
lineW := float32(p.lineTextureWidths[i]) * contentScale
lineH := float32(p.lineTextureHeights[i]) * contentScale
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
currentY -= p.lineHeight
}
// Show accept/decline prompt when scrolled to bottom, scroll hint otherwise
if p.scrollOffset >= p.maxScrollOffset {
p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey)
} else {
hintScale := promptScale
hintW := float32(p.scrollHintTextureWidth) * hintScale
hintH := float32(p.scrollHintTextureHeight) * hintScale
hintX := p.Base.ScreenCenterX - int32(hintW/2)
hintY := promptY
p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey)
}
return nil
}
func (p *TOSPage) Destroy() error {
gl.DeleteTextures(1, &p.titleTextureID)
gl.DeleteTextures(1, &p.promptTextureID)
gl.DeleteTextures(1, &p.scrollHintTextureID)
for _, texID := range p.lineTextureIDs {
if texID != 0 {
gl.DeleteTextures(1, &texID)
}
}
return p.Base.Destroy()
}
+5 -13
View File
@@ -3,13 +3,11 @@ package page
import ( import (
"fmt" "fmt"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
@@ -43,8 +41,9 @@ type UpdateCheckPage struct {
dotTimer float32 dotTimer float32
} }
func (p *UpdateCheckPage) Init(app ApplicationInterface) error { func (p *UpdateCheckPage) Init(ctx *PageCtx) error {
if err := p.Base.Init(app); err != nil { var err error
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("failed base page initialization: %w", err) return fmt.Errorf("failed base page initialization: %w", err)
} }
@@ -62,14 +61,7 @@ func (p *UpdateCheckPage) Init(app ApplicationInterface) error {
} }
// Get scale // Get scale
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := ctx.CfgPtr.InitialFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
scale := float32(48) / float32(baseFontSize) scale := float32(48) / float32(baseFontSize)
// Create checking textures with animated dots // Create checking textures with animated dots
@@ -149,7 +141,7 @@ func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error {
} }
switch e.Keysym.Sym { switch e.Keysym.Sym {
case sdl.K_F3, sdl.K_BACKSPACE: case sdl.K_F3, sdl.K_BACKSPACE:
p.App.UnwindToPage(&Settings{}) p.Context.NavigateTo(SETTINGS)
} }
} }
return nil return nil
+4 -16
View File
@@ -2,7 +2,6 @@ package page
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -11,7 +10,6 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/keybinds" "rhemawavetech.com/rhemawave/internal/keybinds"
"rhemawavetech.com/rhemawave/internal/logger" "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. // 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) return fmt.Errorf("failed base page initialization: %w", err)
} }
// Get application font from database // Get application font from database
applicationFontName, err := db.GetConfigValue("application_font") applicationFontName := "Roboto-Regular"
if err != nil {
return fmt.Errorf("retrieving application_font from db: %w", err)
}
fm := fontManager.Get() fm := fontManager.Get()
font, ok := fm.GetFont(applicationFontName) font, ok := fm.GetFont(applicationFontName)
@@ -629,14 +624,7 @@ func (p *WiFiSetupPage) updatePasswordErrorTexture() {
// Render draws the entire page. // Render draws the entire page.
func (p *WiFiSetupPage) Render() error { func (p *WiFiSetupPage) Render() error {
// Get base font size from database // Get base font size from database
baseFontSizeStr, err := db.GetConfigValue("init_font_size") baseFontSize := p.Context.CfgPtr.StandardFontSize
if err != nil {
return fmt.Errorf("retrieving init_font_size from db: %w", err)
}
baseFontSize, err := strconv.ParseFloat(baseFontSizeStr, 32)
if err != nil {
return fmt.Errorf("parsing init_font_size: %w", err)
}
// Colors & Config // Colors & Config
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255} bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
+5 -12
View File
@@ -8,7 +8,6 @@ import (
"sync" "sync"
"github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/gl/v3.3-core/gl"
"rhemawavetech.com/rhemawave/internal/db"
) )
// Shader represents a compiled GLSL program and its associated data. // Shader represents a compiled GLSL program and its associated data.
@@ -35,14 +34,14 @@ var (
) )
// InitShaderManager initializes the ShaderManager singleton. // InitShaderManager initializes the ShaderManager singleton.
func InitShaderManager() error { func InitShaderManager(shaderDir string) error {
var err error var err error
once.Do(func() { once.Do(func() {
sm := &ShaderManager{ sm := &ShaderManager{
shaders: make(map[string]*Shader), shaders: make(map[string]*Shader),
} }
if e := sm.LoadShaders(); e != nil { if e := sm.LoadShaders(shaderDir); e != nil {
err = fmt.Errorf("could not load shaders: %w", e) err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, e)
return return
} }
instance = sm 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. // 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 // 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) 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 { if err != nil {
return err return err
} }