diff --git a/compositor/compositor.go b/compositor/compositor.go new file mode 100644 index 0000000..20aa1e1 --- /dev/null +++ b/compositor/compositor.go @@ -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() +} diff --git a/main.go b/main.go index cc726f4..2dc3310 100644 --- a/main.go +++ b/main.go @@ -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 ===================