From 6f8cfbffee500f33fdeb8af29edae6569bb7f6e4 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 19 Jun 2026 09:44:55 -0500 Subject: [PATCH] Migrate scroller page to arrow pointer navigation Change ENTER and SPACE to perform the same actions, and also save on every update. --- internal/page/proclamationOptions.go | 148 +++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 21 deletions(-) diff --git a/internal/page/proclamationOptions.go b/internal/page/proclamationOptions.go index 336be67..941a6a8 100644 --- a/internal/page/proclamationOptions.go +++ b/internal/page/proclamationOptions.go @@ -2,6 +2,7 @@ package page import ( "fmt" + "math" "sort" "strconv" "strings" @@ -71,6 +72,15 @@ type ProclamationOptions struct { // 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 { @@ -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)) } + // 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 } @@ -178,6 +205,12 @@ func (p *ProclamationOptions) layout() { 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 @@ -193,10 +226,21 @@ func (p *ProclamationOptions) layout() { item.Label.X = -5000 if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible { visibleIdx := i - p.searchScrollOff - item.Label.X = 60 + 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 } @@ -205,10 +249,23 @@ func (p *ProclamationOptions) layout() { item.Label.X = -5000 if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible { 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) } } + + // 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) { @@ -445,7 +502,12 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error { 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 @@ -494,11 +556,18 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error { p.scrollOffset = bottomIdx p.highlightIndex = bottomIdx 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: 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) } @@ -529,10 +598,21 @@ func (p *ProclamationOptions) acceptChanges() { } 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 }) - 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 { @@ -549,17 +629,9 @@ func (p *ProclamationOptions) Render() error { 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++ { item := p.searchItems[i] - if i == p.searchHighlight { - item.Label.Color = colors.Black - } else if p.activeMap[item.ID] { + if p.activeMap[item.ID] { item.Label.Color = colors.Orange } else { 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) } + // Spinning triangle cursor — always on top, points at the highlighted result + p.renderCursor() + p.renderSearchScrollbar() return nil } @@ -576,16 +651,9 @@ func (p *ProclamationOptions) Render() error { 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++ { item := p.allItems[i] - if i == p.highlightIndex { - item.Label.Color = colors.Black - } else if item.IsCategory { + if item.IsCategory { item.Label.Color = colors.DodgerBlue } else if p.activeMap[item.ID] { 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) } + // 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 { @@ -665,5 +769,7 @@ func (p *ProclamationOptions) Destroy() error { for _, item := range p.allProclamations { gl.DeleteTextures(1, &item.Label.ID) } + gl.DeleteBuffers(1, &p.triVBO) + gl.DeleteVertexArrays(1, &p.triVAO) return nil }