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 }