diff --git a/internal/page/proclamationOptions.go b/internal/page/proclamationOptions.go index 76e4b5e..ea68b98 100644 --- a/internal/page/proclamationOptions.go +++ b/internal/page/proclamationOptions.go @@ -3,14 +3,17 @@ package page import ( "fmt" "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" // Ensure correct GORM import + "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" ) @@ -27,6 +30,7 @@ type FlatItem struct { IsSubCategory bool Depth int Expanded bool + SearchText string // lowercase name + content, pre-built at load time } type ProclamationOptions struct { @@ -37,12 +41,12 @@ type ProclamationOptions struct { title StringItem prompt StringItem - // Tree State + // Browse Tree State allItems []*FlatItem activeMap map[string]bool highlightIndex int - // Scrolling State + // Browse Scrolling State scrollOffset int maxVisible int @@ -55,7 +59,16 @@ type ProclamationOptions struct { indentSize float32 verticalMargin int32 - isSearchActive bool + // 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 } func (p *ProclamationOptions) Init(ctx *PageCtx) error { @@ -83,7 +96,7 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error { p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White) p.title.Scale(float32(64) / float32(ctx.CfgPtr.StandardFontSize)) - p.prompt, _ = NewStringItem("Arrows: Nav Space: Select/Expand Enter: Save Changes Back: Cancel", p.font, colors.White) + 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 @@ -97,17 +110,91 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error { p.activeMap[s.ID] = true } + // Load ALL proclamations into a lookup map for SearchText population + var allProcs []db.Proclamation + if err := p.Context.DbPtr.Find(&allProcs).Error; err != nil { + logger.Error("Failed to fetch proclamations: %s", err) + return err + } + procContentMap := make(map[string]string, len(allProcs)) + for _, pr := range allProcs { + procContentMap[pr.ID] = pr.Content + } + p.allProclamations = []*FlatItem{} + // 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 { + 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)) } + // Expand all root categories so allProclamations can be populated + for i := 0; i < len(p.allItems); i++ { + if p.allItems[i].IsCategory && !p.allItems[i].IsSubCategory { + p.expandAndCollectProcs(i, procContentMap) + p.allItems[i].Expanded = true + itemScale2 := float32(32) / float32(ctx.CfgPtr.StandardFontSize) + pureName := p.allItems[i].Label.content[4:] + gl.DeleteTextures(1, &p.allItems[i].Label.ID) + p.allItems[i].Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White) + p.allItems[i].Label.Scale(itemScale2) + } + } + p.layout() return nil } +// expandAndCollectProcs is like expand() but also populates SearchText and allProclamations. +// Used only during Init to build the initial fully-expanded tree. +func (p *ProclamationOptions) expandAndCollectProcs(index int, procContentMap map[string]string) { + 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 { + fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1) + fi.Expanded = true + newItems = append(newItems, fi) + } + + var procs []db.Proclamation + p.Context.DbPtr.Where("category_id = ?", parentID).Find(&procs) + for _, pr := range procs { + fi := p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1) + fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content) + newItems = append(newItems, fi) + } + + tail := append([]*FlatItem{}, p.allItems[index+1:]...) + p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...) + + // Recurse into subcategories and collect their proclamations too + i := index + 1 + for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth { + item := p.allItems[i] + if item.IsCategory { + p.expandAndCollectProcs(i, procContentMap) + } else { + // It's a leaf — add to pool if not already present + alreadyIn := false + for _, existing := range p.allProclamations { + if existing.ID == item.ID { + alreadyIn = true + break + } + } + if !alreadyIn { + p.allProclamations = append(p.allProclamations, item) + } + } + i++ + } +} + func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem { text := name if isCat && depth == 0 { @@ -135,14 +222,35 @@ func (p *ProclamationOptions) layout() { p.scrollTrackY = 130 p.scrollTrackHeight = int32(startY) - 130 + 50 - for i, item := range p.allItems { - item.Label.X = -5000 // Hidden + 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 = 60 + item.Label.Y = int32(startY - float32(visibleIdx)*47.0) + } + } + 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 - // 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)) + item.Label.X = int32(60.0 + float32(item.Depth)*p.indentSize) + item.Label.Y = int32(startY - float32(visibleIdx)*47.0) } } } @@ -150,7 +258,7 @@ func (p *ProclamationOptions) layout() { func (p *ProclamationOptions) toggleExpansion(index int) { item := p.allItems[index] if !item.IsCategory || item.IsSubCategory { - return // Subcategories are never manually toggled + return } pureName := item.Label.content[4:] @@ -178,25 +286,37 @@ func (p *ProclamationOptions) expand(index int) { 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 // Mark expanded so collapse state is consistent + fi.Expanded = true newItems = append(newItems, fi) } 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)) + // Reuse the existing FlatItem pointer from allProclamations if available, + // so search pool stays valid without duplication. + var fi *FlatItem + for _, existing := range p.allProclamations { + if existing.ID == pr.ID { + fi = existing + break + } + } + if fi == nil { + 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) + } + newItems = append(newItems, fi) } tail := append([]*FlatItem{}, p.allItems[index+1:]...) p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...) - // Auto-expand any subcategories just inserted - // Walk forward: for each newly inserted category, expand it in-place 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) // Recurse — inserts children in-place, loop will walk into them + p.expand(i) } i++ } @@ -206,19 +326,175 @@ 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) + // 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 { + q := strings.ToLower(query) + words := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + }) + for _, w := range words { + if strings.HasPrefix(w, q) { + 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 + } + itemScale := float32(32) / float32(p.Context.CfgPtr.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 } - switch e.Keysym.Sym { + + 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 + p.Context.NavigateTo(SETTINGS) + 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] + } + case sdl.K_RETURN: + 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-- @@ -235,7 +511,7 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error { } case sdl.K_PAGEUP: p.highlightIndex = p.scrollOffset - p.scrollOffset -= (p.maxVisible - 1) + p.scrollOffset -= p.maxVisible - 1 if p.scrollOffset < 0 { p.scrollOffset = 0 p.highlightIndex = 0 @@ -256,23 +532,22 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error { } else if !item.IsSubCategory { p.toggleExpansion(p.highlightIndex) } - case sdl.K_BACKSPACE, sdl.K_ESCAPE: - p.Context.NavigateTo(SETTINGS) default: - // Treat input as search intent, initiate search & filter - p.toggleTextInput() - p.isSearchActive = true + // 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 } -// Turn on and off the text input capture. -// Don't forget to capture the current event, so that you can -// determine which character to start the search with. -func (p *ProclamationOptions) toggleTextInput() {} - func (p *ProclamationOptions) acceptChanges() { p.Context.DbPtr.Transaction(func(tx *gorm.DB) error { tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false) @@ -294,11 +569,43 @@ 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 + } + + // 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] { + 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) + } + + p.renderSearchScrollbar() + return nil + } + + // Browse mode if len(p.allItems) == 0 { return nil } - // 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) @@ -328,22 +635,44 @@ func (p *ProclamationOptions) renderScrollbar() { 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) + 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 - // 2. Render Thumb p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY}, mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight}, @@ -353,7 +682,17 @@ func (p *ProclamationOptions) renderScrollbar() { 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) } return nil