389 lines
11 KiB
Go
389 lines
11 KiB
Go
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: <p>, <br>, <li>, <h1>-<h6>. All other tags are stripped.
|
|
// HTML entities & < > " ' are decoded.
|
|
func parseHTMLToLines(html string, maxCharsPerLine int) []string {
|
|
var lines []string
|
|
|
|
// Replace <br>, <br/>, <br /> with newline markers
|
|
br := strings.NewReplacer(
|
|
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
|
"<BR>", "\n", "<BR/>", "\n", "<BR />", "\n",
|
|
)
|
|
html = br.Replace(html)
|
|
|
|
// Process block-level tags
|
|
// Replace </p>, </h1>-</h6> with paragraph break marker
|
|
for _, tag := range []string{"</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} {
|
|
html = strings.ReplaceAll(html, tag, "\n\n")
|
|
html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
|
|
}
|
|
|
|
// Replace <li> with list prefix marker
|
|
html = strings.ReplaceAll(html, "<li>", "\n- ")
|
|
html = strings.ReplaceAll(html, "<LI>", "\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 <br> 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()
|
|
}
|