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)
794 lines
22 KiB
Go
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
|
|
}
|