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