259 lines
7.2 KiB
Go
259 lines
7.2 KiB
Go
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
|
||
selectedTargetID string
|
||
dashboardFocus dashboardPane
|
||
showUnavailable bool
|
||
detailsOffset int
|
||
driftResolutionTargetID string
|
||
form formState
|
||
confirm bool
|
||
confirmation *app.Confirmation
|
||
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
|
||
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 viewportContent(page.primary, width, height, 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 := viewportContent(page.primary, width, primaryHeight, page.focusLine)
|
||
context := viewportContent(page.context, width, contextHeight, 0)
|
||
return primary + "\n\n" + context
|
||
}
|
||
|
||
func viewportContent(content string, width, height, focusLine int) string {
|
||
if content == "" || width <= 0 || height <= 0 {
|
||
return ""
|
||
}
|
||
view := viewport.New(width, height)
|
||
view.SetContent(strings.TrimRight(content, "\n"))
|
||
if focusLine >= height {
|
||
view.SetYOffset(focusLine - height + 1)
|
||
}
|
||
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)
|
||
}
|