Files
proxy/internal/tui/presentation.go
T

273 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
const (
minimumWidth = 60
minimumHeight = 16
wideWidth = 100
wideHeight = 28
)
type themeMode uint8
const (
themeModeAuto themeMode = iota
themeModeDark
themeModeLight
themeModeNoColor
)
type modelOptions struct {
theme themeMode
output io.Writer
}
// ModelOption configures terminal capabilities used by the complete TUI Model.
type ModelOption func(*modelOptions)
// WithOutput makes presentation capability detection use the same writer that
// Bubble Tea renders to. Production composition should pass tea.WithOutput's
// writer here as well.
func WithOutput(output io.Writer) ModelOption {
return func(options *modelOptions) { options.output = output }
}
func withTheme(mode themeMode) ModelOption {
return func(options *modelOptions) { options.theme = mode }
}
func defaultModelOptions() modelOptions {
return modelOptions{output: os.Stdout}
}
type presentationState struct {
snapshot app.Snapshot
page page
width int
height int
selected int
profileOffset int
profileDetails int
profileDelete *profileDeleteConfirmation
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status string
operationStatus app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
}
// terminalPresentation is the terminal-presentation Module. Application
// coordination supplies one immutable state and calls its single Render
// operation; responsive layout, theming, clipping, and final redaction stay
// inside the module.
type terminalPresentation struct {
theme semanticTheme
}
type terminalPage struct {
header string
primary string
context string
footer string
status string
statusExpanded bool
statusOffset int
focusLine int
primaryOffset int
contextOffset int
dashboard *dashboardRenderState
}
func (p terminalPresentation) Render(state presentationState) string {
width, height := state.width, state.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
if width < minimumWidth || height < minimumHeight {
content := profile.RedactText(p.minimumSizeView(width, height))
return strings.TrimRight(viewportContent(content, width, height, 0), " \n") + "\n"
}
var page terminalPage
switch state.page {
case profilesPage:
page = p.profilesView(state)
case formPage:
page = p.formView(state)
case helpPage:
page = p.helpView(state)
default:
page = p.mainView(state, width, height)
}
page.status = renderStatus(state)
page.statusExpanded = state.errorExpanded && state.currentError != nil
page.statusOffset = state.errorOffset
page = redactTerminalPage(page)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing.
return profile.RedactText(p.renderShell(page, width, height))
}
func renderStatus(state presentationState) string {
var lines []string
if state.exitDeferred {
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
} else if state.operationStatus.Summary != "" {
lines = append(lines, state.operationStatus.Summary)
} else if state.status != "" {
lines = append(lines, state.status)
}
if state.currentError != nil {
errorText := "Current error: " + state.currentError.status.Summary
if state.errorExpanded && state.currentError.detail != "" {
errorText += "\n" + state.currentError.detail
}
lines = append(lines, errorText)
}
return strings.Join(lines, "\n")
}
func redactTerminalPage(page terminalPage) terminalPage {
page.header = profile.RedactText(page.header)
page.primary = profile.RedactText(page.primary)
page.context = profile.RedactText(page.context)
page.footer = profile.RedactText(page.footer)
page.status = profile.RedactText(page.status)
return page
}
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
header := viewportContent(page.header, width, lineCount(page.header), 0)
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
status := ""
if page.status != "" {
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
statusHeight := min(2, lineCount(wrapped))
statusOffset := 0
if page.statusExpanded {
statusHeight = min(lineCount(wrapped), max(3, height/2))
statusOffset = page.statusOffset
}
status = viewportContent(wrapped, width, statusHeight, statusOffset)
}
headerHeight := lineCount(header)
footerHeight := lineCount(footer)
statusHeight := lineCount(status)
separators := 0
if header != "" {
separators++
}
if footer != "" || status != "" {
separators++
}
bodyHeight := height - headerHeight - footerHeight - statusHeight - separators
if bodyHeight < 1 {
bodyHeight = 1
}
body := renderPageBody(page, width, bodyHeight)
if page.dashboard != nil {
body = p.renderDashboard(*page.dashboard, width, bodyHeight)
}
sections := make([]string, 0, 3)
if header != "" {
sections = append(sections, header)
}
sections = append(sections, body)
bottom := joinNonEmpty(status, footer)
if bottom != "" {
sections = append(sections, bottom)
}
frame := strings.Join(sections, "\n\n")
return strings.TrimRight(frame, " \n") + "\n"
}
func renderPageBody(page terminalPage, width, height int) string {
if page.context == "" || height < 5 {
return viewportContentAt(page.primary, width, height, page.primaryOffset, page.focusLine)
}
contextHeight := min(lineCount(page.context), max(3, height/2))
primaryHeight := height - contextHeight - 1
if primaryHeight < 1 {
primaryHeight = 1
contextHeight = max(1, height-primaryHeight-1)
}
primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine)
context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, -1)
return primary + "\n\n" + context
}
func viewportContent(content string, width, height, focusLine int) string {
return viewportContentAt(content, width, height, 0, focusLine)
}
func viewportContentAt(content string, width, height, offset, focusLine int) string {
if content == "" || width <= 0 || height <= 0 {
return ""
}
view := viewport.New(width, height)
view.SetContent(strings.TrimRight(content, "\n"))
top := max(0, offset)
if focusLine >= 0 {
if focusLine < top {
top = focusLine
} else if focusLine >= top+height {
top = focusLine - height + 1
}
}
view.SetYOffset(top)
return view.View()
}
func lineCount(content string) int {
if content == "" {
return 0
}
return strings.Count(strings.TrimRight(content, "\n"), "\n") + 1
}
func joinNonEmpty(values ...string) string {
var present []string
for _, value := range values {
if value != "" {
present = append(present, value)
}
}
return strings.Join(present, "\n")
}
func (p terminalPresentation) minimumSizeView(width, height int) string {
return fmt.Sprintf("%s\n\nMinimum size: 60×16\nCurrent size: %d×%d\nResize the terminal to continue.",
p.theme.warning.Render("Terminal too small"), width, height)
}