fix: harden responsive tui layouts
This commit is contained in:
parent
5e83300ea8
commit
84070d2721
|
|
@ -1543,13 +1543,12 @@ func (m *tuiModel) viewConfirm() string {
|
|||
}
|
||||
body := func(width, height int) string {
|
||||
innerWidth := max(1, width-4)
|
||||
lines := []string{dashboardSection(m.confirm.title), ""}
|
||||
lines = append(lines, wrapCells(m.confirm.target, innerWidth)...)
|
||||
innerHeight := max(1, height-2)
|
||||
message := wrapCells(m.confirm.target, innerWidth)
|
||||
if m.confirm.consequence != "" {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, wrapCells(m.confirm.consequence, innerWidth)...)
|
||||
message = append(message, "")
|
||||
message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
|
||||
}
|
||||
lines = append(lines, "")
|
||||
cancel := "[ Cancel ]"
|
||||
accept := "[ " + m.confirm.verb + " ]"
|
||||
if m.confirm.focus == confirmCancel {
|
||||
|
|
@ -1557,11 +1556,23 @@ func (m *tuiModel) viewConfirm() string {
|
|||
} else {
|
||||
accept = errorStyle.Render("> " + accept)
|
||||
}
|
||||
action := cancel + " " + accept
|
||||
if m.confirm.pending {
|
||||
lines = append(lines, m.confirm.verb+" in progress…")
|
||||
} else {
|
||||
lines = append(lines, cancel+" "+accept)
|
||||
action = m.confirm.verb + " in progress…"
|
||||
}
|
||||
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 renderScreenShell(screenShell{
|
||||
|
|
@ -1983,10 +1994,11 @@ func (m *tuiModel) viewTemplates() string {
|
|||
if index == m.templateList.Index() {
|
||||
marker = "> "
|
||||
}
|
||||
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
||||
if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow {
|
||||
lines = append(lines, " "+dashboardHelp(tpl.template.Description))
|
||||
line := marker + tpl.template.Name + " " + tpl.template.Command
|
||||
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
|
||||
line += " — " + tpl.template.Description
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return renderPaddedPanel(width, height, lines)
|
||||
},
|
||||
|
|
@ -2175,7 +2187,7 @@ func (m *tuiModel) removeTag(name string) {
|
|||
// --- Server list footer ---
|
||||
|
||||
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
||||
width := m.width - 2
|
||||
width := m.width - 3
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ func (m *tuiModel) renderServerDashboard() string {
|
|||
if height <= 0 {
|
||||
height = 40
|
||||
}
|
||||
sizeClass := classifyTerminal(width, height)
|
||||
width = max(1, width-1)
|
||||
|
||||
header := m.renderDashboardHeader(width)
|
||||
notification := m.renderDashboardNotification(width)
|
||||
|
|
@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string {
|
|||
}
|
||||
|
||||
var body string
|
||||
switch classifyTerminal(width, height) {
|
||||
switch sizeClass {
|
||||
case sizeWide:
|
||||
leftWidth := width * 62 / 100
|
||||
rightWidth := width - leftWidth - 1
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func (m *forwardScreenModel) View() string {
|
|||
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
||||
}
|
||||
body := func(width, height int) string {
|
||||
switch classifyTerminal(width, height) {
|
||||
switch classifyShellContent(width) {
|
||||
case sizeWide:
|
||||
leftWidth := width * 70 / 100
|
||||
rightWidth := width - leftWidth - 1
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
|||
func (m *actionMenuModel) View() string {
|
||||
body := func(width, height int) string {
|
||||
listLines := m.actionLines(max(1, height-2))
|
||||
if classifyTerminal(width, height) == sizeWide {
|
||||
if classifyShellContent(width) == sizeWide {
|
||||
leftWidth := width * 48 / 100
|
||||
rightWidth := width - leftWidth - 1
|
||||
selected, _ := m.list.SelectedItem().(actionMenuItem)
|
||||
|
|
@ -360,7 +360,7 @@ func (m *actionMenuModel) View() string {
|
|||
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 {
|
||||
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
|
|||
m := New(servers)
|
||||
m.width, m.height = 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"} {
|
||||
if !strings.Contains(m.View(), want) {
|
||||
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) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
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) {
|
||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||
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
|
||||
|
||||
func (e errText) Error() string { return string(e) }
|
||||
|
|
|
|||
|
|
@ -110,6 +110,17 @@ func splitBlock(block string) []string {
|
|||
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 {
|
||||
vault := "Vault locked"
|
||||
if vaultUnlocked {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
|
|
@ -102,7 +103,7 @@ func (m *tunnelScreenModel) View() string {
|
|||
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
||||
}
|
||||
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)
|
||||
for index := start; index < end; index++ {
|
||||
item := tunnelItem{state: m.tunnels[index]}
|
||||
|
|
@ -110,7 +111,10 @@ func (m *tunnelScreenModel) View() string {
|
|||
if index == m.list.Index() {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue