From 518879dc560ad0058d89c1891393f658af3e0489 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 29 Nov 2025 02:09:54 +0000 Subject: [PATCH] Update menus to wrap for smaller screens. --- internal/ui/bubbletea_ui.go | 97 ++++++++++++++++++++++++++++++------ internal/ui/wizard_styles.go | 90 +++++++++++++++++++++++++++++++++ internal/ui/wizard_views.go | 53 +++++++++++--------- 3 files changed, 201 insertions(+), 39 deletions(-) diff --git a/internal/ui/bubbletea_ui.go b/internal/ui/bubbletea_ui.go index 8081132..46c4013 100644 --- a/internal/ui/bubbletea_ui.go +++ b/internal/ui/bubbletea_ui.go @@ -602,20 +602,82 @@ func (m model) renderMainView() string { footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")) - footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d", - keyStyle.Render("↑↓"), - keyStyle.Render("jk"), - keyStyle.Render("Space"), - keyStyle.Render("n"), - keyStyle.Render("e"), - keyStyle.Render("d"), - keyStyle.Render("b"), - keyStyle.Render("l"), - keyStyle.Render("q"), - len(m.ui.forwardOrder)) + // Get terminal width for footer wrapping + termWidth := m.termWidth + if termWidth == 0 { + termWidth = 120 + } - // Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer) - footerHeight := 2 + // Define key bindings as structured data for flexible rendering + type keyBinding struct { + key string + desc string + } + bindings := []keyBinding{ + {"↑↓/jk", "Navigate"}, + {"Space", "Toggle"}, + {"n", "New"}, + {"e", "Edit"}, + {"d", "Delete"}, + {"b", "Bench"}, + {"l", "Logs"}, + {"q", "Quit"}, + } + + // Build footer lines that fit within terminal width + var footerLines []string + var currentLine strings.Builder + currentLineVisualLen := 0 + + // Calculate how much space we need for the total count suffix + totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder)) + totalSuffixLen := len(totalSuffix) + + // Available width (account for some margin) + availableWidth := termWidth - 4 + + for i, binding := range bindings { + // Build this binding's text + keyRendered := keyStyle.Render(binding.key) + bindingText := keyRendered + ": " + binding.desc + // Visual length without ANSI codes + bindingVisualLen := len(binding.key) + 2 + len(binding.desc) + + // Add separator if not first item on line + separator := "" + separatorLen := 0 + if currentLine.Len() > 0 { + separator = " " + separatorLen = 2 + } + + // Check if this binding fits on current line + // For the last binding, also need to fit the total suffix + neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen + if i == len(bindings)-1 { + neededWidth += totalSuffixLen + } + + if neededWidth > availableWidth && currentLine.Len() > 0 { + // Start a new line + footerLines = append(footerLines, currentLine.String()) + currentLine.Reset() + currentLineVisualLen = 0 + separator = "" + separatorLen = 0 + } + + currentLine.WriteString(separator) + currentLine.WriteString(bindingText) + currentLineVisualLen += separatorLen + bindingVisualLen + } + + // Add total count to the last line + currentLine.WriteString(totalSuffix) + footerLines = append(footerLines, currentLine.String()) + + // Calculate footer height + footerHeight := len(footerLines) + 1 // +1 for the blank line before footer remainingLines := termHeight - currentLines - footerHeight if remainingLines > 0 { b.WriteString(strings.Repeat("\n", remainingLines)) @@ -623,7 +685,12 @@ func (m model) renderMainView() string { // Add footer at bottom b.WriteString("\n") - b.WriteString(footerStyle.Render(footer)) + for i, line := range footerLines { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(footerStyle.Render(line)) + } return b.String() } @@ -736,7 +803,7 @@ func (m model) renderDeleteConfirmation() string { } b.WriteString("\n\n") - b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel")) + b.WriteString(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth))) // Wrap in a box using wizard style boxStyle := lipgloss.NewStyle(). diff --git a/internal/ui/wizard_styles.go b/internal/ui/wizard_styles.go index 5aed4c2..4954fdd 100644 --- a/internal/ui/wizard_styles.go +++ b/internal/ui/wizard_styles.go @@ -194,6 +194,96 @@ func renderTextInput(label, value string, valid bool) string { return b.String() } +// wizardHelpWidth returns an appropriate width for wizard help text +// based on terminal width. For modals, we use a sensible maximum. +func wizardHelpWidth(termWidth int) int { + if termWidth == 0 { + termWidth = 80 + } + // Wizard modals shouldn't be wider than 70 chars typically + // but on narrow terminals, use available space minus padding + maxWidth := 70 + available := termWidth - 10 // account for modal borders and padding + if available < maxWidth { + return available + } + return maxWidth +} + +// wrapHelpText wraps help text to fit within the given width. +// Help text is expected to be in the format "key: action key: action ..." +// separated by double spaces. On smaller screens, it wraps to multiple lines. +func wrapHelpText(text string, width int) string { + if width <= 0 { + width = 80 // Default width + } + + // Account for some padding/margin + availableWidth := width - 4 + if availableWidth < 20 { + availableWidth = 20 + } + + // If text fits, return as-is + if len(text) <= availableWidth { + return helpStyle.Render(text) + } + + // Split by double-space separator (common in help text) + parts := strings.Split(text, " ") + if len(parts) <= 1 { + // No double-space separators, just truncate + if len(text) > availableWidth-3 { + return helpStyle.Render(text[:availableWidth-3] + "...") + } + return helpStyle.Render(text) + } + + var lines []string + var currentLine strings.Builder + + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Check if adding this part would exceed width + addition := part + if currentLine.Len() > 0 { + addition = " " + part + } + + if currentLine.Len()+len(addition) > availableWidth && currentLine.Len() > 0 { + // Start new line + lines = append(lines, currentLine.String()) + currentLine.Reset() + currentLine.WriteString(part) + } else { + if currentLine.Len() > 0 { + currentLine.WriteString(" ") + } + currentLine.WriteString(part) + } + + // Handle last part + if i == len(parts)-1 && currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + } + + // Join with newlines and apply style to each line + var result strings.Builder + for i, line := range lines { + if i > 0 { + result.WriteString("\n") + } + result.WriteString(helpStyle.Render(line)) + } + + return result.String() +} + // overlayContent overlays modal content centered on the base view // Note: base parameter is kept for API compatibility but not used since // lipgloss.Place provides cleaner centering without background artifacts diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index 1d69388..2db93ef 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -109,10 +109,11 @@ func (m model) renderSelectContext() string { } b.WriteString("\n") + helpWidth := wizardHelpWidth(m.termWidth) if wizard.searchFilter != "" { - b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)))) + b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)), helpWidth)) } else { - b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel", helpWidth)) } return b.String() @@ -150,10 +151,11 @@ func (m model) renderSelectNamespace() string { } b.WriteString("\n") + helpWidth := wizardHelpWidth(m.termWidth) if wizard.searchFilter != "" { - b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)))) + b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)), helpWidth)) } else { - b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth)) } return b.String() @@ -192,7 +194,7 @@ func (m model) renderSelectResourceType() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth))) return b.String() } @@ -305,14 +307,15 @@ func (m model) renderEnterResource() string { b.WriteString("\n") // Show appropriate help text based on resource type and filter state + helpWidth := wizardHelpWidth(m.termWidth) if wizard.selectedResourceType == ResourceTypeService { if wizard.searchFilter != "" { - b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)))) + b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)), helpWidth)) } else { - b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth)) } } else { - b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", helpWidth)) } return b.String() @@ -403,7 +406,7 @@ func (m model) renderEnterRemotePort() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth))) } else { // Text input mode (no detected ports or user chose manual entry) if len(wizard.detectedPorts) > 0 { @@ -436,7 +439,7 @@ func (m model) renderEnterRemotePort() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth))) } return b.String() @@ -477,7 +480,7 @@ func (m model) renderEnterLocalPort() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth))) return b.String() } @@ -535,7 +538,7 @@ func (m model) renderConfirmation() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back")) + b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth))) return b.String() } @@ -578,7 +581,7 @@ func (m model) renderSuccess() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select")) + b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select", wizardHelpWidth(m.termWidth))) return b.String() } @@ -641,7 +644,7 @@ func (m model) renderRemoveSelection() string { selectedCount := wizard.getSelectedCount() b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards))) - b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel")) + b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth))) return b.String() } @@ -676,7 +679,7 @@ func (m model) renderRemoveConfirmation() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel")) + b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth))) return b.String() } @@ -740,7 +743,7 @@ func (m model) renderBenchmarkConfig() string { b.WriteString("\n") b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency))) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel")) + b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel", wizardHelpWidth(m.termWidth))) return b.String() } @@ -780,7 +783,7 @@ func (m model) renderBenchmarkRunning() string { b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency))) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("Please wait...")) + b.WriteString(wrapHelpText("Please wait...", wizardHelpWidth(m.termWidth))) return b.String() } @@ -796,14 +799,14 @@ func (m model) renderBenchmarkResults() string { if state.error != nil { b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error))) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth))) return b.String() } if state.results == nil { b.WriteString(mutedStyle.Render("No results available")) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth))) return b.String() } @@ -872,7 +875,7 @@ func (m model) renderBenchmarkResults() string { } b.WriteString("\n") - b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth))) return b.String() } @@ -1076,8 +1079,10 @@ func (m model) renderHTTPLog() string { } b.WriteString("\n") - // Help line at bottom - b.WriteString(helpStyle.Render(" ↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close")) + // Help line at bottom (wrap for smaller screens) + helpText := "↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close" + b.WriteString(" ") + b.WriteString(wrapHelpText(helpText, termWidth-4)) return b.String() } @@ -1273,9 +1278,9 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int if state.copyMessage != "" { b.WriteString(successStyle.Render(state.copyMessage)) b.WriteString(" ") - b.WriteString(helpStyle.Render("↑/↓: Scroll c: Copy Esc: Back")) + b.WriteString(wrapHelpText("↑/↓: Scroll c: Copy Esc: Back", termWidth-10)) } else { - b.WriteString(helpStyle.Render("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back")) + b.WriteString(wrapHelpText("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back", termWidth-10)) } return b.String()