Files
mend/compositor/compositor.go
2026-01-03 23:50:37 +00:00

155 lines
3.3 KiB
Go

/*
I asked for very little ( a dialog box ) from life ( bubbletea ), and even this little was denied me.
I guess asking for an functional overlay was too much in 2025 so here is my own compositor layer.
Idea is simple really I just draw the old ui and then on top draw the new ui, only overwriting what's needed
The main problem is with handling of characters and styles which is "decent" enough here.
NOTE: I had this module written at some point but I don't use it now
- ruinivist, 1Jan26
*/
package compositor
import (
"regexp"
"strings"
"github.com/mattn/go-runewidth"
)
/*
a colored character is like some color followed by one or more "runes" aka characters
since I'm overwriting arbitrary parts of screen, to keep color intact and not have it "split up"
every rune has it's own style now
*/
type Pixel struct {
Char rune
Style string
}
type Grid struct {
Width int
Height int
Rows [][]Pixel
}
func NewGrid(w, h int) *Grid {
rows := make([][]Pixel, h)
for i := range rows {
rows[i] = make([]Pixel, w)
for j := range rows[i] {
rows[i][j] = Pixel{Char: ' ', Style: ""}
}
}
return &Grid{
Width: w,
Height: h,
Rows: rows,
}
}
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) // regex to match ANSI SGR sequences
// what even is ANSI SGR aka escape codes for colors and styles for terminals
// Write content string starting at (startX, startY)
func (g *Grid) Write(startX, startY int, content string) {
x, y := startX, startY
currentStyle := ""
runes := []rune(content) // note that this is now a one dimensional slice
i := 0
for i < len(runes) {
if y >= g.Height {
break
}
// Check for ANSI escape sequence and apply them to the end
if runes[i] == '\x1b' {
// Find the end of the sequence 'm'
end := -1
for j := i; j < len(runes); j++ {
if runes[j] == 'm' {
end = j
break
}
}
if end != -1 {
seq := string(runes[i : end+1])
// this should be a valid ansi sgr seq now
if ansiRegex.MatchString(seq) {
currentStyle = seq
i = end + 1
continue
}
}
}
// line splits
if runes[i] == '\n' {
y++
x = startX
i++
continue
}
// Handle regular character
r := runes[i]
rw := runewidth.RuneWidth(r)
if x+rw <= g.Width {
g.Rows[y][x] = Pixel{Char: r, Style: currentStyle}
// null pad wide chars that span across multiple cells
for k := 1; k < rw; k++ {
if x+k < g.Width {
g.Rows[y][x+k] = Pixel{Char: 0, Style: currentStyle}
}
}
}
x += rw
i++
}
}
// Render gives me back the string representation for terminal
func (g *Grid) Render() string {
var b strings.Builder
currentActiveStyle := ""
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
p := g.Rows[y][x]
// wide chars, these must have been handled already
if p.Char == 0 {
continue
}
// Optimizing state changes
if p.Style != currentActiveStyle {
// when a style changes, the simplest way is to print a "reset" first and
// then the new style
b.WriteString("\x1b[0m") // this means "reset" style of following chars
if p.Style != "" {
b.WriteString(p.Style)
}
currentActiveStyle = p.Style
}
b.WriteRune(p.Char)
}
b.WriteString("\x1b[0m") // end of line reset for wrap artifacts on \n next
currentActiveStyle = ""
if y < g.Height-1 {
b.WriteRune('\n')
}
}
return b.String()
}