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:
Vendored
-7
@@ -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}"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
|
|||||||
+100
-403
@@ -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
|
|
||||||
}
|
|
||||||
selectedAudio, err := db.GetConfigValue("selected_audio")
|
|
||||||
if err != nil || selectedAudio == "" {
|
|
||||||
logger.Warning("No selected_audio configured")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
audioPath := filepath.Join(assetsDir, selectedAudio)
|
|
||||||
if _, err := os.Stat(audioPath); err == nil {
|
|
||||||
logger.Info("Audio file found: %s", audioPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL)
|
|
||||||
|
|
||||||
totalBytes := &atomic.Int64{}
|
|
||||||
bytesWritten := &atomic.Int64{}
|
|
||||||
done := &atomic.Bool{}
|
|
||||||
hasError := &atomic.Bool{}
|
|
||||||
|
|
||||||
downloadPage := &page.DownloadProgressPage{
|
|
||||||
TotalBytes: totalBytes,
|
|
||||||
BytesWritten: bytesWritten,
|
|
||||||
Done: done,
|
|
||||||
HasError: hasError,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer done.Store(true)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil {
|
|
||||||
logger.Error("Failed to create audio directory: %v", err)
|
|
||||||
hasError.Store(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Get(audioFileURL)
|
if err = db.Migrate(DB); err != nil {
|
||||||
if err != nil {
|
logger.Error("application failed to perform database migrations: %v", err)
|
||||||
logger.Error("Failed to download audio file: %v", err)
|
|
||||||
hasError.Store(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
logger.Error("Audio download returned status %d", resp.StatusCode)
|
|
||||||
hasError.Store(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
// Populate the initial Config cache OR create defaults
|
||||||
totalBytes.Store(resp.ContentLength)
|
result = DB.First(&initialConfig)
|
||||||
|
if result.Error != nil {
|
||||||
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
|
logger.Info("First run detected: Creating default configuration...")
|
||||||
|
initialConfig = db.AppConfig{
|
||||||
|
DataDir: dataDir,
|
||||||
|
AssetsDir: dataDir,
|
||||||
|
SystemLevel: "BASIC",
|
||||||
|
SelectedAudioDevice: "default",
|
||||||
|
InitialFontSize: 128,
|
||||||
|
StandardFontSize: 32,
|
||||||
|
LinePattern: "fibonacci",
|
||||||
|
LastVolume: 128,
|
||||||
|
AudioEnabled: true,
|
||||||
}
|
}
|
||||||
|
if err := DB.Create(&initialConfig).Error; err != nil {
|
||||||
tmp := audioPath + ".tmp"
|
logger.Error("failed to insert default config into database: %v", err)
|
||||||
f, err := os.Create(tmp)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to create audio file: %v", err)
|
|
||||||
hasError.Store(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
pw := &progressWriter{w: f, written: bytesWritten}
|
logger.Error("error querying database for initial config: %v", result.Error)
|
||||||
_, 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
|
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())
|
logger.AddDatabase(DB)
|
||||||
}()
|
|
||||||
|
|
||||||
return downloadPage
|
app := &Application{
|
||||||
|
DB: DB,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.refreshCache()
|
||||||
|
|
||||||
|
w, h := app.initializeSDLAndOpenGL()
|
||||||
|
defer app.cleanup()
|
||||||
|
|
||||||
|
app.initializeManagers()
|
||||||
|
app.initializeAudio()
|
||||||
|
app.PageRouter = page.NewRouter(page.PageCtx{
|
||||||
|
CfgPtr: app.Config.Load(),
|
||||||
|
DbPtr: app.DB,
|
||||||
|
W: w,
|
||||||
|
H: h,
|
||||||
|
RefreshConfig: app.refreshCache,
|
||||||
|
})
|
||||||
|
app.runEventLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *Application) initializeSDLAndOpenGL() {
|
func (app *Application) refreshCache() {
|
||||||
|
var freshConfig db.AppConfig
|
||||||
|
if result := app.DB.First(&freshConfig); result.Error != nil {
|
||||||
|
logger.Error("failed to fetch configuration from the database: %v", result.Error)
|
||||||
|
}
|
||||||
|
app.Config.Store(&freshConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) initializeSDLAndOpenGL() (W, H int32) {
|
||||||
logger.Info("Initializing SDL")
|
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
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
var DB *gorm.DB
|
|
||||||
|
|
||||||
// configCache is an in-memory cache of config values to avoid repeated DB queries.
|
|
||||||
var configCache = struct {
|
|
||||||
sync.RWMutex
|
|
||||||
m map[string]string
|
|
||||||
}{m: make(map[string]string)}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
gorm.Model
|
|
||||||
Key string `gorm:"uniqueIndex"`
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Proclamations struct {
|
|
||||||
gorm.Model
|
|
||||||
Title string
|
|
||||||
Content string
|
|
||||||
Commercial bool
|
|
||||||
Selected bool
|
|
||||||
Favorite bool
|
|
||||||
Available bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Log struct {
|
|
||||||
gorm.Model
|
|
||||||
Timestamp string
|
|
||||||
Level string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Log) TableName() string {
|
|
||||||
return "log_entries"
|
|
||||||
}
|
|
||||||
|
|
||||||
type TOSSection struct {
|
|
||||||
gorm.Model
|
|
||||||
SectionNumber int `gorm:"uniqueIndex"`
|
|
||||||
Title string
|
|
||||||
Content string // raw HTML
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultConfigValues = map[string]string{
|
|
||||||
"home_dir": "",
|
|
||||||
"assets_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave
|
|
||||||
"log_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave/logs.log
|
|
||||||
"email_address": "",
|
|
||||||
"license_key": "",
|
|
||||||
"system_type": "home",
|
|
||||||
"system_capabilities": "",
|
|
||||||
"selected_audio": "audio/audio.wav",
|
|
||||||
"audio_device_name": "",
|
|
||||||
"application_font": "Roboto-Regular",
|
|
||||||
"init_font_size": "128",
|
|
||||||
"standard_font_size": "32",
|
|
||||||
"line_pattern": "fibonacci",
|
|
||||||
"last_volume": "128",
|
|
||||||
"audio_enabled": "true",
|
|
||||||
"tos_section_1_accepted": "false",
|
|
||||||
"tos_section_2_accepted": "false",
|
|
||||||
"tos_section_3_accepted": "false",
|
|
||||||
"tos_section_4_accepted": "false",
|
|
||||||
"tos_section_5_accepted": "false",
|
|
||||||
"tos_section_6_accepted": "false",
|
|
||||||
"tos_section_7_accepted": "false",
|
|
||||||
"tos_section_8_accepted": "false",
|
|
||||||
"tos_section_9_accepted": "false",
|
|
||||||
"tos_section_10_accepted": "false",
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitDatabase initializes the SQLite database connection
|
|
||||||
// and performs auto-migration for all models
|
|
||||||
func InitDatabase(dbpath string) error {
|
|
||||||
// Create the directory for the database if it doesn't exist
|
|
||||||
dbDir := filepath.Dir(dbpath)
|
|
||||||
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
|
||||||
log.Printf("Error creating database directory %s: %v", dbDir, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if database file exists
|
|
||||||
_, err := os.Stat(dbpath)
|
|
||||||
dbExists := !os.IsNotExist(err)
|
|
||||||
|
|
||||||
if !dbExists {
|
|
||||||
log.Printf("Database file does not exist at %s, creating new database", dbpath)
|
|
||||||
}
|
|
||||||
|
|
||||||
dsn := fmt.Sprintf("file:%s", dbpath)
|
|
||||||
|
|
||||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error opening database: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get underlying sql.DB to set pragmas
|
|
||||||
sqlDB, err := DB.DB()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error getting underlying database connection: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure SQLite for reliability and performance
|
|
||||||
pragmas := []string{
|
|
||||||
"PRAGMA journal_mode=WAL", // Write-Ahead Logging for better concurrency
|
|
||||||
"PRAGMA synchronous=FULL", // Maximum durability
|
|
||||||
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
|
|
||||||
"PRAGMA busy_timeout=5000", // Wait up to 5 seconds for locks
|
|
||||||
"PRAGMA temp_store=MEMORY", // Keep temp tables in memory
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pragma := range pragmas {
|
|
||||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
|
||||||
logger.Error("Error setting pragma %s: %v", pragma, err)
|
|
||||||
return fmt.Errorf("failed to set pragma: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
logger.Error("Error pinging database: %v", err)
|
|
||||||
return fmt.Errorf("database connection failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Database connected successfully")
|
|
||||||
|
|
||||||
// Auto-migrate models
|
|
||||||
err = DB.AutoMigrate(&Config{}, &Proclamations{}, &Log{}, &TOSSection{})
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error migrating database: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Println("Database migrated successfully")
|
|
||||||
|
|
||||||
// Seed defaults
|
|
||||||
err = ensureDefaultsExist()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error seeding default configuration values: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = seedDefaultProclamations()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error seeding default proclamations: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = seedDefaultTOSSections()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error seeding default TOS sections: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Database initialized successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedDefaultProclamations reads proclamation text files from the assets directory
|
|
||||||
// and populates the database on first run
|
|
||||||
func seedDefaultProclamations() error {
|
|
||||||
var count int64
|
|
||||||
err := DB.Model(&Proclamations{}).Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error counting proclamations: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only seed if table is empty
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the assets directory from config
|
|
||||||
assetsDir, err := GetConfigValue("assets_dir")
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error getting assets_dir: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
proclamationsBaseDir := assetsDir + "/proclamations"
|
|
||||||
|
|
||||||
// Process both home and commercial directories
|
|
||||||
subdirs := []string{"home", "commercial"}
|
|
||||||
totalSeeded := 0
|
|
||||||
|
|
||||||
for _, subdir := range subdirs {
|
|
||||||
proclamationsDir := proclamationsBaseDir + "/" + subdir
|
|
||||||
isCommercial := (subdir == "commercial")
|
|
||||||
|
|
||||||
// Read all .txt files from the subdirectory
|
|
||||||
files, err := os.ReadDir(proclamationsDir)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error reading proclamations directory %s: %v", proclamationsDir, err)
|
|
||||||
continue // Continue with other directories even if one fails
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".txt") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := proclamationsDir + "/" + file.Name()
|
|
||||||
content, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error reading file %s: %v", filePath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract title from filename (remove .txt extension)
|
|
||||||
title := strings.TrimSuffix(file.Name(), ".txt")
|
|
||||||
|
|
||||||
// Select "Relaxation" as the default proclamation
|
|
||||||
isSelected := (title == "Relaxation")
|
|
||||||
|
|
||||||
proclamation := Proclamations{
|
|
||||||
Title: title,
|
|
||||||
Content: string(content),
|
|
||||||
Commercial: isCommercial,
|
|
||||||
Selected: isSelected,
|
|
||||||
Favorite: false,
|
|
||||||
Available: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := DB.Create(&proclamation).Error; err != nil {
|
|
||||||
logger.Error("Error creating proclamation from %s: %v", file.Name(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSelected {
|
|
||||||
logger.Error("Set default proclamation: %s", title)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSeeded++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Error("Seeded %d default proclamations from %s", totalSeeded, proclamationsBaseDir)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfigValue retrieves a configuration value by key.
|
|
||||||
// Results are cached in memory to avoid repeated DB queries.
|
|
||||||
func GetConfigValue(key string) (string, error) {
|
|
||||||
configCache.RLock()
|
|
||||||
if v, ok := configCache.m[key]; ok {
|
|
||||||
configCache.RUnlock()
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
configCache.RUnlock()
|
|
||||||
|
|
||||||
var config Config
|
|
||||||
result := DB.First(&config, "key = ?", key)
|
|
||||||
if result.Error != nil {
|
|
||||||
return "", result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
configCache.Lock()
|
|
||||||
configCache.m[key] = config.Value
|
|
||||||
configCache.Unlock()
|
|
||||||
|
|
||||||
return config.Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfigValues retrieves all configuration key-value pairs
|
|
||||||
// Returns a map of key-value pairs OR any error encountered
|
|
||||||
// This is mainly for compatibility with legacy code, but can
|
|
||||||
// be useful for bulkl config referrals
|
|
||||||
func GetConfigValues() (map[string]string, error) {
|
|
||||||
var configs []Config
|
|
||||||
result := DB.Find(&configs)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, result.Error
|
|
||||||
}
|
|
||||||
configMap := make(map[string]string)
|
|
||||||
for _, config := range configs {
|
|
||||||
configMap[config.Key] = config.Value
|
|
||||||
}
|
|
||||||
return configMap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetConfigValue sets a configuration value by key
|
|
||||||
// Returns any error encountered || nil
|
|
||||||
func SetConfigValue(key, value string) error {
|
|
||||||
// First try to find existing config
|
|
||||||
var config Config
|
|
||||||
result := DB.Where("key = ?", key).First(&config)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
if result.Error == gorm.ErrRecordNotFound {
|
|
||||||
// Record doesn't exist, create new one
|
|
||||||
config = Config{Key: key, Value: value}
|
|
||||||
if err := DB.Create(&config).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to create config value for key %s: %w", key, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Some other error occurred
|
|
||||||
return fmt.Errorf("failed to query config for key %s: %w", key, result.Error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Record exists, update it
|
|
||||||
config.Value = value
|
|
||||||
if err := DB.Save(&config).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to update config value for key %s: %w", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
configCache.Lock()
|
|
||||||
configCache.m[key] = value
|
|
||||||
configCache.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProclamations retrieves all proclamation entries from the database
|
|
||||||
// Returns a slice of Proclamations OR any error encountered
|
|
||||||
func GetProclamations() ([]Proclamations, error) {
|
|
||||||
var proclamations []Proclamations
|
|
||||||
result := DB.Find(&proclamations)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, result.Error
|
|
||||||
}
|
|
||||||
return proclamations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedDefaultTOSSections reads HTML files from the assets directory and populates the
|
|
||||||
// TOS sections table on first run.
|
|
||||||
func seedDefaultTOSSections() error {
|
|
||||||
var count int64
|
|
||||||
if err := DB.Model(&TOSSection{}).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
assetsDir, err := GetConfigValue("assets_dir")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("getting assets_dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tosDir := filepath.Join(assetsDir, "tos")
|
|
||||||
files, err := os.ReadDir(tosDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading tos directory %s: %w", tosDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seeded := 0
|
|
||||||
for _, file := range files {
|
|
||||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".html") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse filename: "01 - WELCOME.html" -> number=1, title="WELCOME"
|
|
||||||
name := strings.TrimSuffix(file.Name(), ".html")
|
|
||||||
parts := strings.SplitN(name, " - ", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
logger.Error("Skipping TOS file with unexpected name format: %s", file.Name())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var sectionNum int
|
|
||||||
if _, err := fmt.Sscanf(parts[0], "%d", §ionNum); err != nil {
|
|
||||||
logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error reading TOS file %s: %v", file.Name(), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
section := TOSSection{
|
|
||||||
SectionNumber: sectionNum,
|
|
||||||
Title: title,
|
|
||||||
Content: string(content),
|
|
||||||
}
|
|
||||||
if err := DB.Create(§ion).Error; err != nil {
|
|
||||||
return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
seeded++
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTOSSections returns all TOS sections ordered by section number.
|
|
||||||
func GetTOSSections() ([]TOSSection, error) {
|
|
||||||
var sections []TOSSection
|
|
||||||
result := DB.Order("section_number asc").Find(§ions)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, result.Error
|
|
||||||
}
|
|
||||||
return sections, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogs retrieves log entries from the database, ordered by creation time ascending.
|
|
||||||
// If limit > 0, at most that many entries are returned.
|
|
||||||
func GetLogs(limit int) ([]Log, error) {
|
|
||||||
var logs []Log
|
|
||||||
query := DB.Order("created_at DESC")
|
|
||||||
if limit > 0 {
|
|
||||||
query = query.Limit(limit)
|
|
||||||
}
|
|
||||||
return logs, query.Find(&logs).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDefaultsExist checks the current configs table rows and inserts any new rows
|
|
||||||
// from defaultConfigValues to the database if they don't exist.
|
|
||||||
// This is essentially an automatic migration for new config values.
|
|
||||||
func ensureDefaultsExist() error {
|
|
||||||
for key, value := range defaultConfigValues {
|
|
||||||
var config Config
|
|
||||||
result := DB.Where("key = ?", key).First(&config)
|
|
||||||
|
|
||||||
// Always re-resolve path-based config values from the current binary location
|
|
||||||
switch key {
|
|
||||||
case "assets_dir":
|
|
||||||
value = resolveAssetsDir()
|
|
||||||
case "log_dir":
|
|
||||||
value = filepath.Join(resolveAssetsDir(), "logs.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Error == gorm.ErrRecordNotFound {
|
|
||||||
// Key doesn't exist, create it with default value
|
|
||||||
if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to create default config for %s: %w", key, err)
|
|
||||||
}
|
|
||||||
log.Printf("Added missing config key: %s", key)
|
|
||||||
} else if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
} else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
|
|
||||||
// Update stale path-based values (e.g. after Nix store update)
|
|
||||||
config.Value = value
|
|
||||||
if err := DB.Save(&config).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to update config for %s: %w", key, err)
|
|
||||||
}
|
|
||||||
log.Printf("Updated stale config key: %s", key)
|
|
||||||
} else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
|
|
||||||
// Migrate renamed audio file
|
|
||||||
config.Value = value
|
|
||||||
if err := DB.Save(&config).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to migrate config for %s: %w", key, err)
|
|
||||||
}
|
|
||||||
log.Printf("Migrated config key: %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AudioEnabled() bool {
|
|
||||||
on, err := GetConfigValue("audio_enabled")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return on == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAssetsDir returns the assets directory. If RHEMAWAVE_ASSETS_DIR is
|
|
||||||
// set, it is used directly (useful for debugging with dlv). Otherwise, it
|
|
||||||
// resolves relative to the running binary: .../bin/rhemawave → .../share/rhemawave.
|
|
||||||
// Falls back to /usr/local/share/rhemawave.
|
|
||||||
func resolveAssetsDir() string {
|
|
||||||
if dir := os.Getenv("RHEMAWAVE_ASSETS_DIR"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err == nil {
|
|
||||||
exe, err = filepath.EvalSymlinks(exe)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
return filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")
|
|
||||||
}
|
|
||||||
return filepath.Join("/usr", "local", "share", "rhemawave")
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,17 +20,17 @@ type ProclamationOptions struct {
|
|||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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++ {
|
||||||
|
|||||||
@@ -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,7 +74,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +92,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,7 +110,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -121,7 +119,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & < > " ' are decoded.
|
|
||||||
func parseHTMLToLines(html string, maxCharsPerLine int) []string {
|
|
||||||
var lines []string
|
|
||||||
|
|
||||||
// Replace <br>, <br/>, <br /> with newline markers
|
|
||||||
br := strings.NewReplacer(
|
|
||||||
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
|
||||||
"<BR>", "\n", "<BR/>", "\n", "<BR />", "\n",
|
|
||||||
)
|
|
||||||
html = br.Replace(html)
|
|
||||||
|
|
||||||
// Process block-level tags
|
|
||||||
// Replace </p>, </h1>-</h6> with paragraph break marker
|
|
||||||
for _, tag := range []string{"</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} {
|
|
||||||
html = strings.ReplaceAll(html, tag, "\n\n")
|
|
||||||
html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace <li> with list prefix marker
|
|
||||||
html = strings.ReplaceAll(html, "<li>", "\n- ")
|
|
||||||
html = strings.ReplaceAll(html, "<LI>", "\n- ")
|
|
||||||
|
|
||||||
// Strip all remaining HTML tags
|
|
||||||
var stripped strings.Builder
|
|
||||||
inTag := false
|
|
||||||
for _, r := range html {
|
|
||||||
if r == '<' {
|
|
||||||
inTag = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '>' {
|
|
||||||
inTag = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inTag {
|
|
||||||
stripped.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text := stripped.String()
|
|
||||||
|
|
||||||
// Decode HTML entities
|
|
||||||
text = strings.ReplaceAll(text, "&", "&")
|
|
||||||
text = strings.ReplaceAll(text, "<", "<")
|
|
||||||
text = strings.ReplaceAll(text, ">", ">")
|
|
||||||
text = strings.ReplaceAll(text, """, "\"")
|
|
||||||
text = strings.ReplaceAll(text, "'", "'")
|
|
||||||
|
|
||||||
// Split into segments on double-newline (paragraph breaks) and single newline (line breaks)
|
|
||||||
paragraphs := strings.Split(text, "\n\n")
|
|
||||||
for i, para := range paragraphs {
|
|
||||||
para = strings.TrimSpace(para)
|
|
||||||
if para == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle single newlines within a paragraph (from <br> tags)
|
|
||||||
sublines := strings.Split(para, "\n")
|
|
||||||
for _, sub := range sublines {
|
|
||||||
sub = strings.TrimSpace(sub)
|
|
||||||
if sub == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wrapped := wrapText(sub, maxCharsPerLine)
|
|
||||||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add paragraph break (empty line) between paragraphs
|
|
||||||
if i < len(paragraphs)-1 {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapText(text string, maxCharsPerLine int) string {
|
|
||||||
var result strings.Builder
|
|
||||||
words := strings.Fields(text)
|
|
||||||
lineLen := 0
|
|
||||||
|
|
||||||
for _, word := range words {
|
|
||||||
if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 {
|
|
||||||
result.WriteString("\n")
|
|
||||||
lineLen = 0
|
|
||||||
}
|
|
||||||
if lineLen > 0 {
|
|
||||||
result.WriteString(" ")
|
|
||||||
lineLen++
|
|
||||||
}
|
|
||||||
result.WriteString(word)
|
|
||||||
lineLen += len(word)
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TOSPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
actualEvent := *event
|
|
||||||
|
|
||||||
switch e := actualEvent.(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN {
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_n:
|
|
||||||
logger.Info("User declined TOS (%s); exiting", p.ConfigKey)
|
|
||||||
p.App.Quit()
|
|
||||||
case sdl.K_y:
|
|
||||||
if p.scrollOffset < p.maxScrollOffset {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.Info("User accepted TOS (%s)", p.ConfigKey)
|
|
||||||
if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil {
|
|
||||||
return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err)
|
|
||||||
}
|
|
||||||
p.App.SchedulePop()
|
|
||||||
case sdl.K_UP:
|
|
||||||
p.scrollOffset -= p.lineHeight
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
p.scrollOffset += p.lineHeight
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEUP:
|
|
||||||
p.scrollOffset -= p.visibleHeight
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEDOWN:
|
|
||||||
p.scrollOffset += p.visibleHeight
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
case sdl.K_HOME:
|
|
||||||
p.scrollOffset = 0
|
|
||||||
case sdl.K_END:
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *sdl.MouseWheelEvent:
|
|
||||||
p.scrollOffset -= e.Y * p.lineHeight * 3
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TOSPage) Update(dt float32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TOSPage) Render() error {
|
|
||||||
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
|
||||||
}
|
|
||||||
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parsing init_font_size: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
textShader, _ := sm.Get("text")
|
|
||||||
|
|
||||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
|
||||||
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
|
|
||||||
|
|
||||||
titleFontSize := int32(48)
|
|
||||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
|
||||||
contentFontSize := int32(32)
|
|
||||||
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
|
||||||
promptFontSize := int32(20)
|
|
||||||
promptScale := float32(promptFontSize) / float32(baseFontSize)
|
|
||||||
|
|
||||||
margin := int32(40)
|
|
||||||
lineSpacing := int32(4)
|
|
||||||
paragraphSpacing := int32(16)
|
|
||||||
p.lineHeight = contentFontSize + lineSpacing
|
|
||||||
|
|
||||||
// Center a content column at ~55% of screen width, left-align text within it
|
|
||||||
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
|
|
||||||
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
|
|
||||||
|
|
||||||
// Title
|
|
||||||
titleH := float32(p.titleTextureHeight) * titleScale
|
|
||||||
titleW := float32(p.titleTextureWidth) * titleScale
|
|
||||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
|
||||||
titleY := p.Base.ScreenHeight - margin - int32(titleH)
|
|
||||||
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, white)
|
|
||||||
|
|
||||||
// Prompt dimensions (calculate even if not shown, for content bounds)
|
|
||||||
promptH := float32(p.promptTextureHeight) * promptScale
|
|
||||||
promptW := float32(p.promptTextureWidth) * promptScale
|
|
||||||
promptX := p.Base.ScreenCenterX - int32(promptW/2)
|
|
||||||
promptY := margin
|
|
||||||
|
|
||||||
// Content area bounds
|
|
||||||
contentTop := titleY - margin
|
|
||||||
contentBottom := promptY + int32(promptH) + margin
|
|
||||||
p.visibleHeight = contentTop - contentBottom
|
|
||||||
|
|
||||||
// Calculate total content height (accounting for paragraph breaks)
|
|
||||||
totalContentHeight := int32(0)
|
|
||||||
for _, line := range p.lines {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
totalContentHeight += paragraphSpacing
|
|
||||||
} else {
|
|
||||||
totalContentHeight += p.lineHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.maxScrollOffset = totalContentHeight - p.visibleHeight
|
|
||||||
if p.maxScrollOffset < 0 {
|
|
||||||
p.maxScrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render visible lines
|
|
||||||
currentY := contentTop - p.lineHeight + p.scrollOffset
|
|
||||||
|
|
||||||
for i, line := range p.lines {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
currentY -= paragraphSpacing
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.lineTextureIDs[i] == 0 {
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip lines outside visible area
|
|
||||||
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
|
||||||
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
|
||||||
|
|
||||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show accept/decline prompt when scrolled to bottom, scroll hint otherwise
|
|
||||||
if p.scrollOffset >= p.maxScrollOffset {
|
|
||||||
p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey)
|
|
||||||
} else {
|
|
||||||
hintScale := promptScale
|
|
||||||
hintW := float32(p.scrollHintTextureWidth) * hintScale
|
|
||||||
hintH := float32(p.scrollHintTextureHeight) * hintScale
|
|
||||||
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
|
||||||
hintY := promptY
|
|
||||||
p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TOSPage) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.titleTextureID)
|
|
||||||
gl.DeleteTextures(1, &p.promptTextureID)
|
|
||||||
gl.DeleteTextures(1, &p.scrollHintTextureID)
|
|
||||||
|
|
||||||
for _, texID := range p.lineTextureIDs {
|
|
||||||
if texID != 0 {
|
|
||||||
gl.DeleteTextures(1, &texID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user