215 lines
5.4 KiB
Go
215 lines
5.4 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
|
||
form formState
|
||
confirm bool
|
||
targetConfirm bool
|
||
profileConfirm bool
|
||
status string
|
||
applying bool
|
||
profileTransition bool
|
||
progressTarget string
|
||
}
|
||
|
||
// 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
|
||
focusLine int
|
||
}
|
||
|
||
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 = state.status
|
||
page = redactTerminalPage(page)
|
||
return p.renderShell(page, width, height)
|
||
}
|
||
|
||
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))
|
||
status = viewportContent(wrapped, width, min(2, lineCount(wrapped)), 0)
|
||
}
|
||
|
||
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)
|
||
|
||
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)
|
||
}
|