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) }