Files
radiantwave-legacy/internal/page/proclamationOptions.go
T
NeilClack 312f5e2a90
build-prod.yml / build (push) Failing after 2s
build-dev.yml / build (push) Failing after 2s
Rename rhemawave to radiantwave throughout
2026-08-08 01:20:11 -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"
"radiantwavetech.com/radiantwave/internal/colors"
"radiantwavetech.com/radiantwave/internal/db"
"radiantwavetech.com/radiantwave/internal/fontManager"
"radiantwavetech.com/radiantwave/internal/logger"
"radiantwavetech.com/radiantwave/internal/shaderManager"
"radiantwavetech.com/radiantwave/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
}