package page import ( "fmt" "strconv" "strings" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/shaderManager" ) type TOSPage struct { Base Title string Content string ConfigKey string titleTextureID uint32 titleTextureWidth int32 titleTextureHeight int32 lines []string lineTextureIDs []uint32 lineTextureWidths []int32 lineTextureHeights []int32 promptTextureID uint32 promptTextureWidth int32 promptTextureHeight int32 scrollHintTextureID uint32 scrollHintTextureWidth int32 scrollHintTextureHeight int32 scrollOffset int32 maxScrollOffset int32 lineHeight int32 visibleHeight int32 } func (p *TOSPage) Init(app ApplicationInterface) error { if err := p.Base.Init(app); err != nil { return fmt.Errorf("failed base page initialization: %w", err) } p.lines = parseHTMLToLines(p.Content, 60) applicationFont, err := db.GetConfigValue("application_font") if err != nil { return fmt.Errorf("retrieving application_font from db: %w", err) } fm := fontManager.Get() textShader, ok := shaderManager.Get().Get("text") if !ok { return fmt.Errorf("unable to fetch text shader") } titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.Title, textShader) if err != nil { return fmt.Errorf("failed to create title texture: %w", err) } p.titleTextureID = titleTextureID p.titleTextureWidth = titleWidth p.titleTextureHeight = titleHeight p.lineTextureIDs = make([]uint32, len(p.lines)) p.lineTextureWidths = make([]int32, len(p.lines)) p.lineTextureHeights = make([]int32, len(p.lines)) for i, line := range p.lines { if strings.TrimSpace(line) == "" { continue } texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader) if err != nil { return fmt.Errorf("failed to create line texture: %w", err) } p.lineTextureIDs[i] = texID p.lineTextureWidths[i] = w p.lineTextureHeights[i] = h } promptText := "Press Y to Accept | Press N to Decline" promptTextureID, promptWidth, promptHeight, err := fm.CreateStringTexture(applicationFont, promptText, textShader) if err != nil { return fmt.Errorf("failed to create prompt texture: %w", err) } p.promptTextureID = promptTextureID p.promptTextureWidth = promptWidth p.promptTextureHeight = promptHeight scrollHintText := "Scroll down: Arrow Keys / PgDn" scrollHintID, scrollHintW, scrollHintH, err := fm.CreateStringTexture(applicationFont, scrollHintText, textShader) if err != nil { return fmt.Errorf("failed to create scroll hint texture: %w", err) } p.scrollHintTextureID = scrollHintID p.scrollHintTextureWidth = scrollHintW p.scrollHintTextureHeight = scrollHintH return nil } // parseHTMLToLines converts HTML content to plain-text lines for rendering. // Supported tags:

,
,

  • ,

    -

    . All other tags are stripped. // HTML entities & < > " ' are decoded. func parseHTMLToLines(html string, maxCharsPerLine int) []string { var lines []string // Replace
    ,
    ,
    with newline markers br := strings.NewReplacer( "
    ", "\n", "
    ", "\n", "
    ", "\n", "
    ", "\n", "
    ", "\n", "
    ", "\n", ) html = br.Replace(html) // Process block-level tags // Replace

    ,
    - with paragraph break marker for _, tag := range []string{"

    ", "", "", "", "", "", ""} { html = strings.ReplaceAll(html, tag, "\n\n") html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n") } // Replace
  • with list prefix marker html = strings.ReplaceAll(html, "
  • ", "\n- ") html = strings.ReplaceAll(html, "
  • ", "\n- ") // Strip all remaining HTML tags var stripped strings.Builder inTag := false for _, r := range html { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { stripped.WriteRune(r) } } text := stripped.String() // Decode HTML entities text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") text = strings.ReplaceAll(text, """, "\"") text = strings.ReplaceAll(text, "'", "'") // Split into segments on double-newline (paragraph breaks) and single newline (line breaks) paragraphs := strings.Split(text, "\n\n") for i, para := range paragraphs { para = strings.TrimSpace(para) if para == "" { continue } // Handle single newlines within a paragraph (from
    tags) sublines := strings.Split(para, "\n") for _, sub := range sublines { sub = strings.TrimSpace(sub) if sub == "" { continue } wrapped := wrapText(sub, maxCharsPerLine) lines = append(lines, strings.Split(wrapped, "\n")...) } // Add paragraph break (empty line) between paragraphs if i < len(paragraphs)-1 { lines = append(lines, "") } } return lines } func wrapText(text string, maxCharsPerLine int) string { var result strings.Builder words := strings.Fields(text) lineLen := 0 for _, word := range words { if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 { result.WriteString("\n") lineLen = 0 } if lineLen > 0 { result.WriteString(" ") lineLen++ } result.WriteString(word) lineLen += len(word) } return result.String() } func (p *TOSPage) HandleEvent(event *sdl.Event) error { actualEvent := *event switch e := actualEvent.(type) { case *sdl.KeyboardEvent: if e.Type == sdl.KEYDOWN { switch e.Keysym.Sym { case sdl.K_n: logger.Info("User declined TOS (%s); exiting", p.ConfigKey) p.App.Quit() case sdl.K_y: if p.scrollOffset < p.maxScrollOffset { return nil } logger.Info("User accepted TOS (%s)", p.ConfigKey) if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil { return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err) } p.App.SchedulePop() case sdl.K_UP: p.scrollOffset -= p.lineHeight if p.scrollOffset < 0 { p.scrollOffset = 0 } case sdl.K_DOWN: p.scrollOffset += p.lineHeight if p.scrollOffset > p.maxScrollOffset { p.scrollOffset = p.maxScrollOffset } case sdl.K_PAGEUP: p.scrollOffset -= p.visibleHeight if p.scrollOffset < 0 { p.scrollOffset = 0 } case sdl.K_PAGEDOWN: p.scrollOffset += p.visibleHeight if p.scrollOffset > p.maxScrollOffset { p.scrollOffset = p.maxScrollOffset } case sdl.K_HOME: p.scrollOffset = 0 case sdl.K_END: p.scrollOffset = p.maxScrollOffset } } case *sdl.MouseWheelEvent: p.scrollOffset -= e.Y * p.lineHeight * 3 if p.scrollOffset < 0 { p.scrollOffset = 0 } if p.scrollOffset > p.maxScrollOffset { p.scrollOffset = p.maxScrollOffset } } return nil } func (p *TOSPage) Update(dt float32) error { return nil } func (p *TOSPage) Render() error { baseFontSizeStr, err := db.GetConfigValue("init_font_size") if err != nil { return fmt.Errorf("retrieving init_font_size from db: %w", err) } baseFontSize, err := strconv.Atoi(baseFontSizeStr) if err != nil { return fmt.Errorf("parsing init_font_size: %w", err) } sm := shaderManager.Get() textShader, _ := sm.Get("text") white := sdl.Color{R: 255, G: 255, B: 255, A: 255} grey := sdl.Color{R: 180, G: 180, B: 180, A: 255} titleFontSize := int32(48) titleScale := float32(titleFontSize) / float32(baseFontSize) contentFontSize := int32(32) contentScale := float32(contentFontSize) / float32(baseFontSize) promptFontSize := int32(20) promptScale := float32(promptFontSize) / float32(baseFontSize) margin := int32(40) lineSpacing := int32(4) paragraphSpacing := int32(16) p.lineHeight = contentFontSize + lineSpacing // Center a content column at ~55% of screen width, left-align text within it contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55) contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2 // Title titleH := float32(p.titleTextureHeight) * titleScale titleW := float32(p.titleTextureWidth) * titleScale titleX := p.Base.ScreenCenterX - int32(titleW/2) titleY := p.Base.ScreenHeight - margin - int32(titleH) p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, white) // Prompt dimensions (calculate even if not shown, for content bounds) promptH := float32(p.promptTextureHeight) * promptScale promptW := float32(p.promptTextureWidth) * promptScale promptX := p.Base.ScreenCenterX - int32(promptW/2) promptY := margin // Content area bounds contentTop := titleY - margin contentBottom := promptY + int32(promptH) + margin p.visibleHeight = contentTop - contentBottom // Calculate total content height (accounting for paragraph breaks) totalContentHeight := int32(0) for _, line := range p.lines { if strings.TrimSpace(line) == "" { totalContentHeight += paragraphSpacing } else { totalContentHeight += p.lineHeight } } p.maxScrollOffset = totalContentHeight - p.visibleHeight if p.maxScrollOffset < 0 { p.maxScrollOffset = 0 } // Render visible lines currentY := contentTop - p.lineHeight + p.scrollOffset for i, line := range p.lines { if strings.TrimSpace(line) == "" { currentY -= paragraphSpacing continue } if p.lineTextureIDs[i] == 0 { currentY -= p.lineHeight continue } // Skip lines outside visible area if currentY > contentTop || currentY+p.lineHeight < contentBottom { currentY -= p.lineHeight continue } lineW := float32(p.lineTextureWidths[i]) * contentScale lineH := float32(p.lineTextureHeights[i]) * contentScale p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white) currentY -= p.lineHeight } // Show accept/decline prompt when scrolled to bottom, scroll hint otherwise if p.scrollOffset >= p.maxScrollOffset { p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey) } else { hintScale := promptScale hintW := float32(p.scrollHintTextureWidth) * hintScale hintH := float32(p.scrollHintTextureHeight) * hintScale hintX := p.Base.ScreenCenterX - int32(hintW/2) hintY := promptY p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey) } return nil } func (p *TOSPage) Destroy() error { gl.DeleteTextures(1, &p.titleTextureID) gl.DeleteTextures(1, &p.promptTextureID) gl.DeleteTextures(1, &p.scrollHintTextureID) for _, texID := range p.lineTextureIDs { if texID != 0 { gl.DeleteTextures(1, &texID) } } return p.Base.Destroy() }