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 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 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 = state.status 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 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) 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) }