feat(tui-go): transparent modal backdrops

Modals now composite over a dimmed copy of the screen behind them instead of an opaque
scrim, so the transcript stays faintly visible around a modal (opencode-style). center()
stashes the frame's base (View sets m.lastBase), strips+dims it to a faint scrim, and
splices the modal box over it (ANSI-aware via ansi.Truncate/TruncateLeft so the side
margins keep the dimmed content). An idempotence guard passes an already-full-screen
modal through unchanged, so the existing double-center builders don't get re-dimmed.
Geometry tests assert the composite still fills the screen exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:40:52 +00:00
parent 85bfddf1c4
commit 26621567ac
4 changed files with 126 additions and 6 deletions
+5
View File
@@ -311,6 +311,11 @@ type Model struct {
snapshotPhase bool snapshotPhase bool
pendingEvents []protocol.ServerMessage pendingEvents []protocol.ServerMessage
// lastBase is the full-screen render behind the active modal, stashed by View each
// frame so center() can composite a modal over a dimmed copy of it (transparent
// backdrop) instead of an opaque scrim. Transient render scratch — not real state.
lastBase string
// approval steering input buffer // approval steering input buffer
steerBuffer string steerBuffer string
steering bool steering bool
@@ -0,0 +1,52 @@
package app
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
func fullScreen(ch string, w, h int) string {
row := strings.Repeat(ch, w)
rows := make([]string, h)
for i := range rows {
rows[i] = row
}
return strings.Join(rows, "\n")
}
// A composited modal must still fill the whole screen exactly (h lines, each w cols),
// so the alt-screen never shows a torn/short region around a "transparent" modal.
func TestCompositeOverlayGeometry(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 40, 12
m.lastBase = fullScreen("x", 40, 12)
modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere")
out := m.center(modal)
lines := strings.Split(out, "\n")
if len(lines) != 12 {
t.Fatalf("height = %d, want 12", len(lines))
}
for i, ln := range lines {
if w := ansi.StringWidth(ln); w != 40 {
t.Fatalf("line %d visible width = %d, want 40", i, w)
}
}
}
// The idempotence guard: a modal that is already full-screen (a builder that centered
// internally, then got re-centered) must pass through unchanged — not get re-dimmed.
func TestCenterIdempotentOnFullScreen(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 30, 8
m.lastBase = fullScreen("y", 30, 8)
full := fullScreen("M", 30, 8)
if got := m.center(full); got != full {
t.Fatal("center should pass a full-screen modal through unchanged")
}
}
+67 -6
View File
@@ -9,13 +9,74 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal // center draws a modal centered over a *dimmed* copy of the screen behind it, so the
// is recomputed from state every frame, so there is no separate show/restore path // transcript stays faintly visible around the modal (a transparent backdrop) rather
// to desync (the "diff won't come back" bug class can't occur here). // than being hidden by an opaque scrim. Immediate-mode: recomputed from state every
// frame, so there is no show/restore path to desync.
//
// Idempotence guard: some modal builders call center() internally and then get centered
// again by renderOverlay. The inner call already produced a full-screen composite, so a
// second call (input already width×height) returns it unchanged instead of dimming the
// modal itself. Falls back to an opaque scrim when no base was stashed (defensive).
func (m Model) center(modal string) string { func (m Model) center(modal string) string {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, if m.lastBase == "" {
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
}
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
return modal // already a full-screen composite; don't re-dim it
}
return m.compositeOverlay(m.lastBase, modal)
}
// compositeOverlay strips + dims base into a faint scrim, then splices the modal box
// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind.
// base and the result are both m.width × m.height.
func (m Model) compositeOverlay(base, modal string) string {
w, h := m.width, m.height
dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep)
baseLines := strings.Split(base, "\n")
scrim := make([]string, h)
for i := 0; i < h; i++ {
raw := ""
if i < len(baseLines) {
raw = ansi.Strip(baseLines[i])
}
scrim[i] = dim.Render(padRightRaw(raw, w))
}
modalLines := strings.Split(modal, "\n")
mw := lipgloss.Width(modal)
top := (h - len(modalLines)) / 2
left := (w - mw) / 2
if top < 0 {
top = 0
}
if left < 0 {
left = 0
}
for r, ml := range modalLines {
row := top + r
if row < 0 || row >= h {
continue
}
leftPart := ansi.Truncate(scrim[row], left, "")
rightPart := ansi.TruncateLeft(scrim[row], left+mw, "")
scrim[row] = leftPart + ml + rightPart
}
return strings.Join(scrim, "\n")
}
// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates).
func padRightRaw(s string, w int) string {
switch sw := ansi.StringWidth(s); {
case sw > w:
return ansi.Truncate(s, w, "")
case sw < w:
return s + strings.Repeat(" ", w-sw)
default:
return s
}
} }
func (m Model) modalWidth() int { func (m Model) modalWidth() int {
+2
View File
@@ -54,6 +54,8 @@ func (m Model) View() string {
bottom, bottom,
m.renderFooter(), m.renderFooter(),
) )
// Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base
if m.displayState() == StateClarification { if m.displayState() == StateClarification {
return m.center(m.clarificationModal()) return m.center(m.clarificationModal())