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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user