Files
radiantwave-legacy/internal/page/proclamationOptions.go
T
NeilClack 2735af5fbb refactor: remove cached AppConfig pointer and read configuration directly from DB
Replace in-memory atomic AppConfig caching with direct DB queries via db.GetConfig
across application and page layers. This simplifies state management and ensures
all components always operate on the latest persisted configuration.

Key changes:
- Remove Application.Config atomic cache and refreshCache mechanism
- Eliminate PageCtx.CfgPtr and pass DB handle instead
- Replace all cfg reads with db.GetConfig(app.DB)
- Update mixer, audio, UI pages, and initialization paths accordingly
- Explicitly set AppConfig table name to avoid GORM pluralization issues
- Reduce DB connection pooling (SetMaxIdleConns(1))
- Standardize application font to SpaceMonoNerdFont-Regular
- Lower default audio volume (1.0 → 0.2)
- Add Unicode checkmark glyph to font map

Behavioral impact:
- Configuration is no longer cached in memory
- Every config access now hits the DB (more consistent, slightly more overhead)
2026-06-26 22:51:32 -05:00

794 lines
22 KiB
Go

package page
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"unicode"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
"rhemawavetech.com/rhemawave/internal/sync"
)
// Scrollbar Visuals
var (
colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30}
colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100}
)
type FlatItem struct {
Label StringItem
ID string
IsCategory bool
IsSubCategory bool
Depth int
Expanded bool
SearchText string // lowercase name + content, pre-built at load time
}
type ProclamationOptions struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
// Browse Tree State
allItems []*FlatItem
activeMap map[string]bool
highlightIndex int
// Browse Scrolling State
scrollOffset int
maxVisible int
// Scrollbar Dimens
scrollTrackWidth int32
scrollTrackX int32
scrollTrackHeight int32
scrollTrackY int32
indentSize float32
verticalMargin int32
// Search State
isSearchActive bool
searchQuery string
searchInput StringItem
searchItems []*FlatItem // filtered view — pointers into allProclamations
searchHighlight int
searchScrollOff int
// Full flat proclamation pool (pointers shared with allItems leaves)
allProclamations []*FlatItem
// Spinning triangle cursor — same shape/animation as AudioOptions —
// that points at whichever row is currently highlighted.
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
triVisible bool
spinElapsed float32
}
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
p.verticalMargin = 25
p.indentSize = 40.0
p.activeMap = make(map[string]bool)
p.allItems = []*FlatItem{}
p.scrollOffset = 0
p.isSearchActive = false
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
var err error
// Syncronize proclamations first
var allProcs []db.Proclamation
allProcs, err = sync.SyncProclamations(ctx.DbPtr)
if err != nil {
// sync failed, fallback to database
ctx.DbPtr.Find(&allProcs)
}
sync.SyncFrequencies(ctx.DbPtr)
fm := fontManager.Get()
font, _ := fm.GetFont("SpaceMonoNerdFont-Regular")
p.font = font
sm := shaderManager.Get()
p.textShader, _ = sm.Get("text")
p.solidShader, _ = sm.Get("solid_color")
p.Base.Init(ctx)
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
p.title.Scale(float32(64) / float32(config.StandardFontSize))
p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White)
p.prompt.Scale(itemScale * 0.8)
// Calculation for visible area
areaHeight := float32(p.ScreenHeight) - 250
p.maxVisible = int(areaHeight / 47)
// Load current selections
var selected []db.Proclamation
ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
for _, s := range selected {
if s.ID == defaultProclamationID {
logger.Debug("Found default proclamation in selected list: %s", s.ID)
}
p.activeMap[s.ID] = true
}
procContentMap := make(map[string]string, len(allProcs))
for _, pr := range allProcs {
if pr.ID == defaultProclamationID {
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
}
procContentMap[pr.ID] = pr.Content
}
p.allProclamations = []*FlatItem{}
for _, pr := range allProcs {
fi := p.createFlatItem(pr.Name, pr.ID, false, 0)
fi.SearchText = strings.ToLower(pr.Name)
p.allProclamations = append(p.allProclamations, fi)
}
// Load Root Categories
var parentCategories []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories)
for _, c := range parentCategories {
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
}
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords.
// Mirrors the spinning cursor used in AudioOptions.
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
-0.5, -0.5,
}
gl.GenVertexArrays(1, &p.triVAO)
gl.GenBuffers(1, &p.triVBO)
gl.BindVertexArray(p.triVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.layout()
return nil
}
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
text := name
if isCat && depth == 0 {
text = "[+] " + name
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
lbl, _ := NewStringItem(text, p.font, colors.White)
lbl.Scale(itemScale)
return &FlatItem{
Label: lbl,
ID: id,
IsCategory: isCat,
IsSubCategory: isCat && depth > 0,
Depth: depth,
}
}
func (p *ProclamationOptions) layout() {
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = 40
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.ScreenHeight - 60
startY := float32(p.ScreenHeight - 150)
p.scrollTrackY = 130
p.scrollTrackHeight = int32(startY) - 130 + 50
// Reserve room on the left of the list for the spinning cursor
// (triangleSize wide, cursorGap away from the text it points at),
// ahead of the original left margin used for list rows.
cursorGap := int32(12)
listBaseX := int32(60) + int32(triangleSize) + cursorGap
if p.isSearchActive {
// Position search bar label
p.searchInput.X = 60
p.searchInput.Y = 100
// Hide all browse items
for _, item := range p.allItems {
item.Label.X = -5000
}
// Position filtered search results
for i, item := range p.searchItems {
item.Label.X = -5000
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
visibleIdx := i - p.searchScrollOff
item.Label.X = listBaseX
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
}
}
// Point the cursor at the highlighted search result, if it's
// currently scrolled into view.
p.triVisible = false
if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) &&
p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
target := p.searchItems[p.searchHighlight].Label
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(target.Y) + float32(target.H)/2
p.triVisible = true
}
return
}
// Browse mode
for i, item := range p.allItems {
item.Label.X = -5000
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
visibleIdx := i - p.scrollOffset
item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
}
}
// Point the cursor at the highlighted browse row, if it's currently
// scrolled into view. Since target.X already includes the row's depth
// indentation, the cursor shifts in and out with it automatically —
// the gap between cursor and text stays constant regardless of depth.
p.triVisible = false
if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) &&
p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
target := p.allItems[p.highlightIndex].Label
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(target.Y) + float32(target.H)/2
p.triVisible = true
}
}
func (p *ProclamationOptions) toggleExpansion(index int) {
item := p.allItems[index]
if !item.IsCategory || item.IsSubCategory {
return
}
pureName := item.Label.content[4:]
gl.DeleteTextures(1, &item.Label.ID)
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
if item.Expanded {
p.collapse(index)
item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White)
} else {
p.expand(index)
item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White)
}
item.Label.Scale(itemScale)
item.Expanded = !item.Expanded
p.layout()
}
func (p *ProclamationOptions) expand(index int) {
parent := p.allItems[index]
parentID, _ := strconv.Atoi(parent.ID)
var newItems []*FlatItem
// Load subcategories
var cats []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
for _, c := range cats {
fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1)
fi.Expanded = true
newItems = append(newItems, fi)
}
// Load proclamations for this category
var procs []db.Proclamation
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
// Sort proclamations alphabetically by name
sort.Slice(procs, func(i, j int) bool {
return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name)
})
for _, pr := range procs {
var fi *FlatItem
// Reuse existing proclamation from allProclamations if available
for _, existing := range p.allProclamations {
if existing.ID == pr.ID {
fi = existing
break
}
}
if fi == nil {
// Safety fallback
fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1)
fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content)
p.allProclamations = append(p.allProclamations, fi)
}
fi.Depth = parent.Depth + 1
newItems = append(newItems, fi)
}
// Insert new items after parent
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
// Recurse into subcategories
i := index + 1
for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth {
if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 {
p.expand(i)
}
i++
}
}
func (p *ProclamationOptions) collapse(index int) {
depth := p.allItems[index].Depth
i := index + 1
for i < len(p.allItems) && p.allItems[i].Depth > depth {
// Do NOT delete textures here — leaves are shared with allProclamations.
// Only delete textures for category items that aren't in the proclamation pool.
if p.allItems[i].IsCategory {
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
}
i++
}
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
}
// matchesWordPrefix returns true if any word in text starts with query (case-insensitive).
func matchesWordPrefix(text, query string) bool {
query = strings.ToLower(query)
text = strings.ToLower(text)
// Split text into words (non-alphanumeric boundaries)
words := strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
for _, word := range words {
if strings.HasPrefix(word, query) {
return true
}
}
return false
}
func (p *ProclamationOptions) buildSearchList() {
p.searchItems = nil
if p.searchQuery == "" {
return
}
for _, item := range p.allProclamations {
if matchesWordPrefix(item.SearchText, p.searchQuery) {
p.searchItems = append(p.searchItems, item)
}
}
if p.searchHighlight >= len(p.searchItems) {
p.searchHighlight = 0
}
}
func (p *ProclamationOptions) rebuildSearchLabel() {
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
p.searchInput.ID = 0
}
if p.searchQuery == "" {
return
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
itemScale := float32(32) / float32(config.StandardFontSize)
p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow)
p.searchInput.Scale(itemScale * 1.1)
}
func (p *ProclamationOptions) enterSearchMode(firstRune rune) {
p.searchQuery = string(firstRune)
p.isSearchActive = true
p.searchScrollOff = 0
p.searchHighlight = 0
p.buildSearchList()
p.rebuildSearchLabel()
}
func (p *ProclamationOptions) exitSearchMode() {
p.searchQuery = ""
p.isSearchActive = false
p.searchItems = nil
p.searchHighlight = 0
p.searchScrollOff = 0
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
p.searchInput.ID = 0
}
}
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
sym := e.Keysym.Sym
// --- Backspace: always handled specially ---
if sym == sdl.K_BACKSPACE {
if p.isSearchActive {
if len(p.searchQuery) > 0 {
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
}
if p.searchQuery == "" {
p.exitSearchMode()
} else {
p.buildSearchList()
p.rebuildSearchLabel()
}
p.layout()
return nil
}
// Browse mode backspace — navigate back
return nil
}
// --- Escape ---
if sym == sdl.K_ESCAPE {
if p.isSearchActive {
p.exitSearchMode()
p.layout()
return nil
}
p.Context.NavigateTo(SETTINGS)
return nil
}
// --- Search mode input handling ---
if p.isSearchActive {
switch sym {
case sdl.K_UP:
if p.searchHighlight > 0 {
p.searchHighlight--
if p.searchHighlight < p.searchScrollOff {
p.searchScrollOff--
}
}
case sdl.K_DOWN:
if p.searchHighlight < len(p.searchItems)-1 {
p.searchHighlight++
if p.searchHighlight >= p.searchScrollOff+p.maxVisible {
p.searchScrollOff++
}
}
case sdl.K_PAGEUP:
p.searchHighlight = p.searchScrollOff
p.searchScrollOff -= p.maxVisible - 1
if p.searchScrollOff < 0 {
p.searchScrollOff = 0
p.searchHighlight = 0
}
case sdl.K_PAGEDOWN:
bottom := p.searchScrollOff + p.maxVisible - 1
if bottom >= len(p.searchItems) {
bottom = len(p.searchItems) - 1
}
p.searchScrollOff = bottom
p.searchHighlight = bottom
case sdl.K_SPACE:
if len(p.searchItems) > 0 {
item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
p.acceptChanges()
case sdl.K_RETURN:
if len(p.searchItems) > 0 {
item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
p.acceptChanges()
default:
// Append printable characters to search query
r := rune(sym)
if r >= 33 && r <= 126 {
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
r = unicode.ToUpper(r)
}
p.searchQuery += string(r)
p.buildSearchList()
p.rebuildSearchLabel()
}
}
p.layout()
return nil
}
// --- Browse mode ---
switch sym {
case sdl.K_UP:
if p.highlightIndex > 0 {
p.highlightIndex--
if p.highlightIndex < p.scrollOffset {
p.scrollOffset--
}
}
case sdl.K_DOWN:
if p.highlightIndex < len(p.allItems)-1 {
p.highlightIndex++
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
}
}
case sdl.K_PAGEUP:
p.highlightIndex = p.scrollOffset
p.scrollOffset -= p.maxVisible - 1
if p.scrollOffset < 0 {
p.scrollOffset = 0
p.highlightIndex = 0
}
case sdl.K_PAGEDOWN:
bottomIdx := p.scrollOffset + p.maxVisible - 1
if bottomIdx >= len(p.allItems) {
bottomIdx = len(p.allItems) - 1
}
p.scrollOffset = bottomIdx
p.highlightIndex = bottomIdx
case sdl.K_RETURN:
item := p.allItems[p.highlightIndex]
if !item.IsCategory { // If the item is NOT a Category
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
p.acceptChanges()
} else if !item.IsSubCategory {
p.toggleExpansion(p.highlightIndex)
}
case sdl.K_SPACE:
item := p.allItems[p.highlightIndex]
if !item.IsCategory { // If the item is NOT a Category
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
p.acceptChanges()
} else if !item.IsSubCategory {
p.toggleExpansion(p.highlightIndex)
}
default:
// Any printable character enters search mode
r := rune(sym)
if r >= 33 && r <= 126 {
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
r = unicode.ToUpper(r)
}
p.enterSearchMode(r)
}
}
p.layout()
}
return nil
}
func (p *ProclamationOptions) acceptChanges() {
p.Context.DbPtr.Transaction(func(tx *gorm.DB) error {
tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false)
var ids []string
for id, active := range p.activeMap {
if active {
ids = append(ids, id)
}
}
if len(ids) > 0 {
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
logger.Debug("Saving selected state for %s\n", ids)
}
return nil
})
}
// Update advances the spinning-cursor animation. The cursor's *position* is
// recomputed in layout() whenever the highlighted row changes; this only
// drives the spin itself, frame to frame.
func (p *ProclamationOptions) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
p.spinElapsed -= 1.0
}
return nil
}
func (p *ProclamationOptions) Render() error {
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
if p.isSearchActive {
// Search bar label
if p.searchInput.ID != 0 {
p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color)
}
if len(p.searchItems) == 0 {
return nil
}
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
item := p.searchItems[i]
if p.activeMap[item.ID] {
item.Label.Color = colors.Orange
} else {
item.Label.Color = colors.White
}
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
}
// Spinning triangle cursor — always on top, points at the highlighted result
p.renderCursor()
p.renderSearchScrollbar()
return nil
}
// Browse mode
if len(p.allItems) == 0 {
return nil
}
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
item := p.allItems[i]
if item.IsCategory {
item.Label.Color = colors.DodgerBlue
} else if p.activeMap[item.ID] {
item.Label.Color = colors.Orange
} else {
item.Label.Color = colors.White
}
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
}
// Spinning triangle cursor — always on top, points at the highlighted row
p.renderCursor()
p.renderScrollbar()
return nil
}
// renderCursor draws the spinning triangle cursor pointing at whichever row
// is currently highlighted, in either browse or search mode. Identical
// shape/animation to the cursor in AudioOptions.
func (p *ProclamationOptions) renderCursor() error {
if !p.triVisible {
return nil
}
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
mvp := p.Projection.Mul4(model)
gl.UseProgram(p.solidShader.ProgramID)
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
gl.Uniform4f(
p.solidShader.LocColor,
float32(colors.White.R)/255.0,
float32(colors.White.G)/255.0,
float32(colors.White.B)/255.0,
float32(colors.White.A)/255.0,
)
gl.BindVertexArray(p.triVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 3)
gl.BindVertexArray(0)
gl.UseProgram(0)
return nil
}
func (p *ProclamationOptions) renderScrollbar() {
total := len(p.allItems)
if total <= p.maxVisible {
return
}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
colorScrollTrack)
visibleRatio := float32(p.maxVisible) / float32(total)
scrollRatio := float32(p.scrollOffset) / float32(total)
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
if thumbHeight < 20 {
thumbHeight = 20
}
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
colorScrollThumb)
}
func (p *ProclamationOptions) renderSearchScrollbar() {
total := len(p.searchItems)
if total <= p.maxVisible {
return
}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
colorScrollTrack)
visibleRatio := float32(p.maxVisible) / float32(total)
scrollRatio := float32(p.searchScrollOff) / float32(total)
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
if thumbHeight < 20 {
thumbHeight = 20
}
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
colorScrollThumb)
}
func (p *ProclamationOptions) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
if p.searchInput.ID != 0 {
gl.DeleteTextures(1, &p.searchInput.ID)
}
// Delete browse category textures
for _, item := range p.allItems {
if item.IsCategory {
gl.DeleteTextures(1, &item.Label.ID)
}
}
// Delete proclamation textures via the pool (single source of truth)
for _, item := range p.allProclamations {
gl.DeleteTextures(1, &item.Label.ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}