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
+67 -6
View File
@@ -9,13 +9,74 @@ import (
"github.com/charmbracelet/x/ansi"
)
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
// is recomputed from state every frame, so there is no separate show/restore path
// to desync (the "diff won't come back" bug class can't occur here).
// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
// transcript stays faintly visible around the modal (a transparent backdrop) rather
// 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 {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
if m.lastBase == "" {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
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 {