fix: harden responsive tui layouts

This commit is contained in:
mirivlad 2026-08-14 08:11:27 +08:00
parent 5e83300ea8
commit 84070d2721
7 changed files with 145 additions and 18 deletions

View File

@ -1543,13 +1543,12 @@ func (m *tuiModel) viewConfirm() string {
} }
body := func(width, height int) string { body := func(width, height int) string {
innerWidth := max(1, width-4) innerWidth := max(1, width-4)
lines := []string{dashboardSection(m.confirm.title), ""} innerHeight := max(1, height-2)
lines = append(lines, wrapCells(m.confirm.target, innerWidth)...) message := wrapCells(m.confirm.target, innerWidth)
if m.confirm.consequence != "" { if m.confirm.consequence != "" {
lines = append(lines, "") message = append(message, "")
lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...) message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
} }
lines = append(lines, "")
cancel := "[ Cancel ]" cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]" accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel { if m.confirm.focus == confirmCancel {
@ -1557,11 +1556,23 @@ func (m *tuiModel) viewConfirm() string {
} else { } else {
accept = errorStyle.Render("> " + accept) accept = errorStyle.Render("> " + accept)
} }
action := cancel + " " + accept
if m.confirm.pending { if m.confirm.pending {
lines = append(lines, m.confirm.verb+" in progress…") action = m.confirm.verb + " in progress…"
} else {
lines = append(lines, cancel+" "+accept)
} }
messageRows := max(0, innerHeight-2)
if len(message) > messageRows {
message = message[:messageRows]
if len(message) > 0 {
message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth)
}
}
lines := []string{dashboardSection(m.confirm.title)}
lines = append(lines, message...)
for len(lines) < innerHeight-1 {
lines = append(lines, "")
}
lines = append(lines, action)
return renderPaddedPanel(width, height, lines) return renderPaddedPanel(width, height, lines)
} }
return renderScreenShell(screenShell{ return renderScreenShell(screenShell{
@ -1983,10 +1994,11 @@ func (m *tuiModel) viewTemplates() string {
if index == m.templateList.Index() { if index == m.templateList.Index() {
marker = "> " marker = "> "
} }
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command) line := marker + tpl.template.Name + " " + tpl.template.Command
if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow { if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
lines = append(lines, " "+dashboardHelp(tpl.template.Description)) line += " — " + tpl.template.Description
} }
lines = append(lines, line)
} }
return renderPaddedPanel(width, height, lines) return renderPaddedPanel(width, height, lines)
}, },
@ -2175,7 +2187,7 @@ func (m *tuiModel) removeTag(name string) {
// --- Server list footer --- // --- Server list footer ---
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
width := m.width - 2 width := m.width - 3
if width <= 0 { if width <= 0 {
width = 80 width = 80
} }

View File

@ -16,6 +16,8 @@ func (m *tuiModel) renderServerDashboard() string {
if height <= 0 { if height <= 0 {
height = 40 height = 40
} }
sizeClass := classifyTerminal(width, height)
width = max(1, width-1)
header := m.renderDashboardHeader(width) header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width) notification := m.renderDashboardNotification(width)
@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string {
} }
var body string var body string
switch classifyTerminal(width, height) { switch sizeClass {
case sizeWide: case sizeWide:
leftWidth := width * 62 / 100 leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1 rightWidth := width - leftWidth - 1

View File

@ -84,7 +84,7 @@ func (m *forwardScreenModel) View() string {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
} }
body := func(width, height int) string { body := func(width, height int) string {
switch classifyTerminal(width, height) { switch classifyShellContent(width) {
case sizeWide: case sizeWide:
leftWidth := width * 70 / 100 leftWidth := width * 70 / 100
rightWidth := width - leftWidth - 1 rightWidth := width - leftWidth - 1

View File

@ -349,7 +349,7 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
func (m *actionMenuModel) View() string { func (m *actionMenuModel) View() string {
body := func(width, height int) string { body := func(width, height int) string {
listLines := m.actionLines(max(1, height-2)) listLines := m.actionLines(max(1, height-2))
if classifyTerminal(width, height) == sizeWide { if classifyShellContent(width) == sizeWide {
leftWidth := width * 48 / 100 leftWidth := width * 48 / 100
rightWidth := width - leftWidth - 1 rightWidth := width - leftWidth - 1
selected, _ := m.list.SelectedItem().(actionMenuItem) selected, _ := m.list.SelectedItem().(actionMenuItem)
@ -360,7 +360,7 @@ func (m *actionMenuModel) View() string {
renderPaddedPanel(rightWidth, height, detail), rightWidth, renderPaddedPanel(rightWidth, height, detail), rightWidth,
) )
} }
if classifyTerminal(width, height) == sizeMedium { if classifyShellContent(width) == sizeMedium {
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 { if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
listLines = append(listLines, "", dashboardSection("Selected"), selected.description) listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
} }

View File

@ -1,6 +1,7 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@ -46,6 +47,7 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
m := New(servers) m := New(servers)
m.width, m.height = size.width, size.height m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height) assertViewFits(t, m.View(), size.width, size.height)
assertRightMargin(t, m.View(), size.width)
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} { for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
if !strings.Contains(m.View(), want) { if !strings.Contains(m.View(), want) {
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View()) t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
@ -168,6 +170,27 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
} }
} }
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
m.width, m.height = size.width, size.height
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestHelpScreensUseUnifiedShell(t *testing.T) { func TestHelpScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
for name, view := range map[string]string{ for name, view := range map[string]string{
@ -218,6 +241,72 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) {
} }
} }
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
covered := map[screen]string{
screenList: "dashboard",
screenForm: "server form",
screenSearch: "manager matrix",
screenTags: "manager matrix",
screenTagInput: "manager matrix",
screenTemplates: "manager matrix",
screenTemplateForm: "template form",
screenTemplatePicker: "manager matrix",
screenTemplateMode: "manager matrix",
screenBackgroundResults: "manager matrix",
screenHelp: "help matrix",
screenActionMenu: "action matrix",
screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix",
screenTunnelManager: "manager matrix",
screenConfirm: "confirmation matrix",
screenFullHelp: "help matrix",
}
for value := screenList; value <= screenFullHelp; value++ {
if _, ok := covered[value]; !ok {
t.Fatalf("screen %d is missing from the layout matrix", value)
}
}
}
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
for _, tt := range []struct {
contentWidth int
want terminalSizeClass
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
if got := classifyShellContent(tt.contentWidth); got != tt.want {
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
}
}
}
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
tunnelModel := newTunnelScreenModel(60, 16)
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
tunnelModel.rebuildList()
view := tunnelModel.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
t.Fatalf("tunnel error was lost:\n%s", view)
}
}
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
m := New(nil)
m.width, m.height = 60, 16
templates := make([]*model.CommandTemplate, 20)
for index := range templates {
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
}
m.setTemplates(templates)
m.templateList.Select(len(templates) - 1)
m.screen = screenTemplates
view := m.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "> template-19") {
t.Fatalf("selected template is outside viewport:\n%s", view)
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) { func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height) form := newTemplateFormModel(nil, size.width, size.height)
@ -284,6 +373,15 @@ func assertUnifiedScreen(t *testing.T, view string, width, height int) {
} }
} }
func assertRightMargin(t *testing.T, view string, width int) {
t.Helper()
for index, line := range strings.Split(view, "\n") {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
type errText string type errText string
func (e errText) Error() string { return string(e) } func (e errText) Error() string { return string(e) }

View File

@ -110,6 +110,17 @@ func splitBlock(block string) []string {
return strings.Split(strings.TrimRight(block, "\n"), "\n") return strings.Split(strings.TrimRight(block, "\n"), "\n")
} }
func classifyShellContent(contentWidth int) terminalSizeClass {
terminalWidth := contentWidth + 1
if terminalWidth >= 100 {
return sizeWide
}
if terminalWidth >= 70 {
return sizeMedium
}
return sizeNarrow
}
func shellStatus(vaultUnlocked bool, detail string) string { func shellStatus(vaultUnlocked bool, detail string) string {
vault := "Vault locked" vault := "Vault locked"
if vaultUnlocked { if vaultUnlocked {

View File

@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@ -102,7 +103,7 @@ func (m *tunnelScreenModel) View() string {
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")}) return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
} }
capacity := max(1, height-2) capacity := max(1, height-2)
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/2)) start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
lines := make([]string, 0, capacity) lines := make([]string, 0, capacity)
for index := start; index < end; index++ { for index := start; index < end; index++ {
item := tunnelItem{state: m.tunnels[index]} item := tunnelItem{state: m.tunnels[index]}
@ -110,7 +111,10 @@ func (m *tunnelScreenModel) View() string {
if index == m.list.Index() { if index == m.list.Index() {
marker = "> " marker = "> "
} }
lines = append(lines, marker+item.Title(), " "+item.Description()) lines = append(lines, marker+item.Title())
for _, description := range strings.Split(item.Description(), "\n") {
lines = append(lines, " "+strings.TrimSpace(description))
}
} }
return renderPaddedPanel(width, height, lines) return renderPaddedPanel(width, height, lines)
} }