add a custom compositing layer

This commit is contained in:
2026-01-01 05:15:40 +00:00
parent 4fdf207e50
commit 2cc3451603
2 changed files with 202 additions and 21 deletions
+152
View File
@@ -0,0 +1,152 @@
/*
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.
- 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()
}
+50 -21
View File
@@ -2,12 +2,12 @@ package main
import (
"fmt"
"mend/compositor"
fs "mend/fstree"
"mend/styles"
"os"
"time"
fs "mend/fstree"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -44,10 +44,11 @@ type model struct {
tree *fs.FsTree
// spinner needs to be state as I need to update the spinner on
// each tick in update func
spinner spinner.Model
loading bool
viewport viewport.Model
hoverLine int // track which line is being hovered
spinner spinner.Model
loading bool
viewport viewport.Model
hoverLine int // track which line is being hovered
showDialog bool
}
// initial model state
@@ -65,6 +66,7 @@ func createModel() model {
loading: true,
viewport: viewport.New(0, 0),
hoverLine: -1, // no hover initially
showDialog: false,
}
}
@@ -129,6 +131,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "d", "D":
m.showDialog = !m.showDialog
return m, nil
}
switch msg.String() {
case "up", "k":
if err := m.tree.MoveUp(); err != nil {
fmt.Println("Error moving up:", err)
@@ -154,26 +162,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) View() string {
var baseUI string
if m.loading {
return fmt.Sprintf("%s Loading files...", m.spinner.View())
baseUI = fmt.Sprintf("%s Loading files...", m.spinner.View())
} else {
left := lipgloss.NewStyle().
Width(m.width).
Render(m.tree.Render(m.hoverLine))
right := lipgloss.NewStyle().
Width(m.terminalWidth - m.width - 1).
Height(m.terminalHeight).
Render(m.viewport.View())
divider := lipgloss.NewStyle().
Width(1).
Height(m.terminalHeight).
Background(styles.Primary).
Render("")
baseUI = lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
}
left := lipgloss.NewStyle().
Width(m.width).
Render(m.tree.Render(m.hoverLine))
// no compositing needed, optimised return
if !m.showDialog {
return baseUI
}
right := lipgloss.NewStyle().
Width(m.terminalWidth - m.width - 1).
Height(m.terminalHeight).
Render(m.viewport.View())
// manual compositing
grid := compositor.NewGrid(m.terminalWidth, m.terminalHeight)
grid.Write(0, 0, baseUI)
dialog := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(styles.Primary).
Background(lipgloss.Color("#222")).
Foreground(lipgloss.Color("#FFF")).
Padding(1, 2).
Align(lipgloss.Center).
Render("this is a sample dialog\npress d to close")
divider := lipgloss.NewStyle().
Width(1).
Height(m.terminalHeight).
Background(styles.Primary).
Render("") // everything is a string in tui
return lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
grid.Write(2, 2, dialog)
return grid.Render()
}
// =================== bubbletea ui fns ===================