Migrate scroller page to arrow pointer navigation

Change ENTER and SPACE to perform the same actions, and also save on every update.
This commit is contained in:
NeilClack
2026-06-19 09:44:55 -05:00
parent 4cd24375cd
commit 6f8cfbffee
+127 -21
View File
@@ -2,6 +2,7 @@ package page
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -71,6 +72,15 @@ type ProclamationOptions struct {
// Full flat proclamation pool (pointers shared with allItems leaves) // Full flat proclamation pool (pointers shared with allItems leaves)
allProclamations []*FlatItem 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 { func (p *ProclamationOptions) Init(ctx *PageCtx) error {
@@ -147,6 +157,23 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0)) 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() p.layout()
return nil return nil
} }
@@ -178,6 +205,12 @@ func (p *ProclamationOptions) layout() {
p.scrollTrackY = 130 p.scrollTrackY = 130
p.scrollTrackHeight = int32(startY) - 130 + 50 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 { if p.isSearchActive {
// Position search bar label // Position search bar label
p.searchInput.X = 60 p.searchInput.X = 60
@@ -193,10 +226,21 @@ func (p *ProclamationOptions) layout() {
item.Label.X = -5000 item.Label.X = -5000
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible { if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
visibleIdx := i - p.searchScrollOff visibleIdx := i - p.searchScrollOff
item.Label.X = 60 item.Label.X = listBaseX
item.Label.Y = int32(startY - float32(visibleIdx)*47.0) 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 return
} }
@@ -205,10 +249,23 @@ func (p *ProclamationOptions) layout() {
item.Label.X = -5000 item.Label.X = -5000
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible { if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
visibleIdx := i - p.scrollOffset visibleIdx := i - p.scrollOffset
item.Label.X = int32(60.0 + float32(item.Depth)*p.indentSize) item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
item.Label.Y = int32(startY - float32(visibleIdx)*47.0) 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) { func (p *ProclamationOptions) toggleExpansion(index int) {
@@ -445,7 +502,12 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
item := p.searchItems[p.searchHighlight] item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID] p.activeMap[item.ID] = !p.activeMap[item.ID]
} }
p.acceptChanges()
case sdl.K_RETURN: case sdl.K_RETURN:
if len(p.searchItems) > 0 {
item := p.searchItems[p.searchHighlight]
p.activeMap[item.ID] = !p.activeMap[item.ID]
}
p.acceptChanges() p.acceptChanges()
default: default:
// Append printable characters to search query // Append printable characters to search query
@@ -494,11 +556,18 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
p.scrollOffset = bottomIdx p.scrollOffset = bottomIdx
p.highlightIndex = bottomIdx p.highlightIndex = bottomIdx
case sdl.K_RETURN: case sdl.K_RETURN:
p.acceptChanges() 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: case sdl.K_SPACE:
item := p.allItems[p.highlightIndex] item := p.allItems[p.highlightIndex]
if !item.IsCategory { // If the item is NOT a Category 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.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
p.acceptChanges()
} else if !item.IsSubCategory { } else if !item.IsSubCategory {
p.toggleExpansion(p.highlightIndex) p.toggleExpansion(p.highlightIndex)
} }
@@ -529,10 +598,21 @@ func (p *ProclamationOptions) acceptChanges() {
} }
if len(ids) > 0 { if len(ids) > 0 {
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true) tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
logger.Debug("Saving selected state for %s\n", ids)
} }
return nil return nil
}) })
p.Context.NavigateTo(SCROLLER) }
// 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 { func (p *ProclamationOptions) Render() error {
@@ -549,17 +629,9 @@ func (p *ProclamationOptions) Render() error {
return nil return nil
} }
// Highlight box for search results
if p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
target := p.searchItems[p.searchHighlight].Label
p.RenderSolidColorQuad(p.solidShader, target.Position(), target.RawDimensions(), colors.White)
}
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ { for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
item := p.searchItems[i] item := p.searchItems[i]
if i == p.searchHighlight { if p.activeMap[item.ID] {
item.Label.Color = colors.Black
} else if p.activeMap[item.ID] {
item.Label.Color = colors.Orange item.Label.Color = colors.Orange
} else { } else {
item.Label.Color = colors.White item.Label.Color = colors.White
@@ -567,6 +639,9 @@ func (p *ProclamationOptions) Render() error {
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color) 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() p.renderSearchScrollbar()
return nil return nil
} }
@@ -576,16 +651,9 @@ func (p *ProclamationOptions) Render() error {
return nil return nil
} }
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++ { for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
item := p.allItems[i] item := p.allItems[i]
if i == p.highlightIndex { if item.IsCategory {
item.Label.Color = colors.Black
} else if item.IsCategory {
item.Label.Color = colors.DodgerBlue item.Label.Color = colors.DodgerBlue
} else if p.activeMap[item.ID] { } else if p.activeMap[item.ID] {
item.Label.Color = colors.Orange item.Label.Color = colors.Orange
@@ -595,10 +663,46 @@ func (p *ProclamationOptions) Render() error {
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color) 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() p.renderScrollbar()
return nil 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() { func (p *ProclamationOptions) renderScrollbar() {
total := len(p.allItems) total := len(p.allItems)
if total <= p.maxVisible { if total <= p.maxVisible {
@@ -665,5 +769,7 @@ func (p *ProclamationOptions) Destroy() error {
for _, item := range p.allProclamations { for _, item := range p.allProclamations {
gl.DeleteTextures(1, &item.Label.ID) gl.DeleteTextures(1, &item.Label.ID)
} }
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil return nil
} }