List proclamations inside of categories

F2 also redirects when no proclamations are selected (the default)
This commit is contained in:
NeilClack
2026-03-24 11:19:50 -05:00
parent 8d23841391
commit 9b6bae9511
3 changed files with 359 additions and 251 deletions
+247 -157
View File
@@ -2,134 +2,194 @@ package page
import (
"fmt"
"strconv"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"gorm.io/gorm" // Ensure correct GORM import
"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"
)
// 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
Depth int
Expanded bool
}
type ProclamationOptions struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
proclamations []StringItem
proclamationIDs []string
activeMap map[int]bool // Maps display index to selected state
highlightIndex int
verticalMargin int32
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
// Tree State
allItems []*FlatItem
activeMap map[string]bool
highlightIndex int
// Scrolling State
scrollOffset int
maxVisible int
// Scrollbar Dimens
scrollTrackWidth int32
scrollTrackX int32
scrollTrackHeight int32
scrollTrackY int32
indentSize float32
verticalMargin int32
}
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.activeMap = make(map[int]bool)
p.proclamationIDs = make([]string, 0)
p.verticalMargin = 25
p.indentSize = 40.0
p.activeMap = make(map[string]bool)
p.allItems = []*FlatItem{}
p.scrollOffset = 0
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
// Get font
fm := fontManager.Get()
font, ok := fm.GetFont("Roboto-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
font, _ := fm.GetFont("Roboto-Regular")
p.font = font
// Get shaders
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
p.textShader, _ = sm.Get("text")
p.solidShader, _ = sm.Get("solid_color")
p.Base.Init(ctx)
itemScale := float32(32) / float32(ctx.CfgPtr.StandardFontSize)
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
p.title.Scale(float32(64) / float32(ctx.CfgPtr.StandardFontSize))
p.prompt, _ = NewStringItem("Arrows: Nav | Enter: Exp/Col | Space: Toggle | A: Accept | Back: Cancel", 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
p.Context.DbPtr.Where("selected = ?", true).Find(&selected)
for _, s := range selected {
p.activeMap[s.ID] = true
}
p.solidShader, ok = sm.Get("solid_color")
if !ok {
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
}
// Initialize base
if err := p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
// Get font size from database
baseFontSize := ctx.CfgPtr.StandardFontSize
// Calculate scales
titleScale := float32(64) / float32(baseFontSize)
itemScale := float32(32) / float32(baseFontSize)
// Create title
p.title, err = NewStringItem("Select Proclamations", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
p.title.Scale(titleScale)
// Create prompt
p.prompt, err = NewStringItem("Arrow Keys to navigate | Space to toggle | F2 to run | Enter to return", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
p.prompt.Scale(itemScale)
// Load proclamations from database
var proclamations []db.Proclamation
if result := p.Context.DbPtr.Find(&proclamations); result.Error != nil {
logger.Error("unable to query database for proclamations: %v", result.Error)
return fmt.Errorf("%s", result.Error)
}
// Create UI items for each available proclamation
for _, proclamation := range proclamations {
if !proclamation.Available {
continue
}
label, err := NewStringItem(proclamation.Name, p.font, colors.White)
if err != nil {
return fmt.Errorf("creating proclamation label: %w", err)
}
label.Scale(itemScale)
p.proclamations = append(p.proclamations, label)
p.proclamationIDs = append(p.proclamationIDs, proclamation.ID)
// Set initial selection state
if proclamation.Selected {
p.activeMap[len(p.proclamations)-1] = true
}
// Load Root Categories
var roots []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&roots)
for _, c := range roots {
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
}
p.layout()
return nil
}
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
text := name
if isCat {
text = "[+] " + name
}
itemScale := float32(32) / float32(p.Context.CfgPtr.StandardFontSize)
lbl, _ := NewStringItem(text, p.font, colors.White)
lbl.Scale(itemScale)
return &FlatItem{Label: lbl, ID: id, IsCategory: isCat, Depth: depth}
}
func (p *ProclamationOptions) layout() {
// Position title at bottom
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Position prompt above title
p.title.Y = 40
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
p.prompt.Y = p.ScreenHeight - 60
// Position proclamation items stacked upward from prompt
for i := range p.proclamations {
p.proclamations[i].X = p.ScreenCenterX - p.proclamations[i].W/2
if i == 0 {
p.proclamations[i].Y = p.prompt.Y - p.verticalMargin*2 - p.proclamations[i].H
} else {
p.proclamations[i].Y = p.proclamations[i-1].Y - p.proclamations[i].H - 10
startY := float32(p.ScreenHeight - 150)
p.scrollTrackY = 130
p.scrollTrackHeight = int32(startY) - 130 + 50
for i, item := range p.allItems {
item.Label.X = -5000 // Hidden
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
visibleIdx := i - p.scrollOffset
// Cast to int32 for StringItem coordinates
item.Label.X = int32(60.0 + (float32(item.Depth) * p.indentSize))
item.Label.Y = int32(startY - (float32(visibleIdx) * 47.0))
}
}
}
func (p *ProclamationOptions) toggleExpansion(index int) {
item := p.allItems[index]
if !item.IsCategory {
return
}
pureName := item.Label.content[4:]
gl.DeleteTextures(1, &item.Label.ID)
itemScale := float32(32) / float32(p.Context.CfgPtr.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
var cats []db.ProclamationCategory
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
for _, c := range cats {
newItems = append(newItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1))
}
var procs []db.Proclamation
p.Context.DbPtr.Where("category_id = ?", parentID).Find(&procs)
for _, pr := range procs {
newItems = append(newItems, p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1))
}
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
}
func (p *ProclamationOptions) collapse(index int) {
depth := p.allItems[index].Depth
i := index + 1
for i < len(p.allItems) && p.allItems[i].Depth > depth {
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
i++
}
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
}
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
@@ -140,100 +200,130 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
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.proclamations)-1 {
if p.highlightIndex < len(p.allItems)-1 {
p.highlightIndex++
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
}
}
case sdl.K_BACKSPACE:
p.Context.NavigateTo(SETTINGS)
case sdl.K_SPACE:
// Toggle selection for highlighted item
if p.activeMap[p.highlightIndex] {
delete(p.activeMap, p.highlightIndex)
} else {
p.activeMap[p.highlightIndex] = true
case sdl.K_PAGEUP:
p.highlightIndex = p.scrollOffset
p.scrollOffset -= (p.maxVisible - 1)
if p.scrollOffset < 0 {
p.scrollOffset = 0
p.highlightIndex = 0
}
// Immediately save to database
if err := p.toggleProclamation(p.highlightIndex); err != nil {
return fmt.Errorf("failed to toggle proclamation: %w", err)
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:
// Just return to settings - no saving needed
p.toggleExpansion(p.highlightIndex)
case sdl.K_SPACE:
item := p.allItems[p.highlightIndex]
if !item.IsCategory {
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
case sdl.K_a:
p.acceptChanges()
case sdl.K_BACKSPACE, sdl.K_ESCAPE:
p.Context.NavigateTo(SETTINGS)
}
p.layout()
}
return nil
}
// toggleProclamation immediately updates the database for the proclamation at the given display index
func (p *ProclamationOptions) toggleProclamation(displayIndex int) error {
if displayIndex < 0 || displayIndex >= len(p.proclamationIDs) {
return fmt.Errorf("invalid display index %d", displayIndex)
}
dbID := p.proclamationIDs[displayIndex]
isSelected := p.activeMap[displayIndex]
// Get the proclamation from database
var proclamation db.Proclamation
proclamation.ID = dbID
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
proclamation.Selected = isSelected
if result := p.Context.DbPtr.Save(&proclamation); result.Error != nil {
logger.Error("failed to update proclamation %s: %v", proclamation.ID, result.Error)
return fmt.Errorf("%s", result.Error)
}
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)
}
return nil
})
p.Context.NavigateTo(SETTINGS)
}
func (p *ProclamationOptions) Render() error {
// Always render title and prompt
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)
// No proclamations to display
if len(p.proclamations) == 0 {
if len(p.allItems) == 0 {
return nil
}
// Render highlight box behind selected item
p.RenderSolidColorQuad(
p.solidShader,
p.proclamations[p.highlightIndex].Position(),
p.proclamations[p.highlightIndex].RawDimensions(),
colors.White,
)
// Render proclamation items with appropriate colors
for i, item := range p.proclamations {
if p.activeMap[i] {
// Selected items are green
item.Color = colors.Green
} else if i == p.highlightIndex {
// Highlighted item is black (shows on white background)
item.Color = colors.Black
} else {
// Normal items are white
item.Color = colors.White
}
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
// Highlight Box
if p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
target := p.allItems[p.highlightIndex].Label
p.RenderSolidColorQuad(p.solidShader, target.Position(), target.RawDimensions(), colors.White)
}
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
item := p.allItems[i]
if i == p.highlightIndex {
item.Label.Color = colors.Black
} else if item.IsCategory {
item.Label.Color = colors.Cyan
} else if p.activeMap[item.ID] {
item.Label.Color = colors.Green
} else {
item.Label.Color = colors.White
}
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
}
p.renderScrollbar()
return nil
}
func (p *ProclamationOptions) renderScrollbar() {
total := len(p.allItems)
if total <= p.maxVisible {
return
}
// 1. Render Track
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
colorScrollTrack)
// Thumb Math
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)
// 2. Render Thumb
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)
for _, item := range p.proclamations {
gl.DeleteTextures(1, &item.ID)
for _, item := range p.allItems {
gl.DeleteTextures(1, &item.Label.ID)
}
return nil
}