feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user