feat: fix proxy compatibility and establish TUI foundation

This commit is contained in:
proxyctl coordinator
2026-07-17 20:30:52 +08:00
parent 0f4c122eff
commit daeb788eac
34 changed files with 1521 additions and 176 deletions
+214
View File
@@ -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)
}