feat: add responsive tui layouts

This commit is contained in:
mirivlad 2026-08-14 03:42:43 +08:00
parent 16f4f9937f
commit 460b4496a0
9 changed files with 820 additions and 341 deletions

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.52.0
@ -19,7 +20,6 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect

View File

@ -372,6 +372,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.actionMenu != nil { if m.actionMenu != nil {
m.actionMenu.width = msg.Width m.actionMenu.width = msg.Width
m.actionMenu.height = msg.Height
m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height)) m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height))
} }
m.templateList.SetSize(msg.Width, managerListHeight(msg.Height)) m.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
@ -1126,6 +1127,9 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m *tuiModel) View() string { func (m *tuiModel) View() string {
if classifyTerminal(m.width, m.height) == sizeBelowFloor {
return minimumSizeView(m.width)
}
var b strings.Builder var b strings.Builder
switch m.screen { switch m.screen {
@ -1194,10 +1198,10 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewConfirm()) b.WriteString(m.viewConfirm())
} }
if m.err != nil { if m.screen != screenList && m.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err))) b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
} }
if m.success != "" { if m.screen != screenList && m.success != "" {
b.WriteString("\n" + successStyle.Render(m.success)) b.WriteString("\n" + successStyle.Render(m.success))
} }
@ -1663,101 +1667,7 @@ func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m *tuiModel) viewServerList() string { func (m *tuiModel) viewServerList() string {
var b strings.Builder return m.renderServerDashboard()
selectedAlias := ""
if item, ok := m.list.SelectedItem().(serverItem); ok && item.server != nil {
selectedAlias = item.server.Alias
}
b.WriteString(titleStyle.Render(fmt.Sprintf("sshkeeper %d servers", len(m.servers))))
b.WriteString("\n")
vaultStatus := "Vault locked"
if m.vaultUnlocked {
vaultStatus = "Vault unlocked"
}
b.WriteString(helpStyle.Render(fmt.Sprintf("%s | %s", vaultStatus, testSummary(m.servers))))
b.WriteString("\n\n")
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "ROUTE", "AUTH", "GROUP", "STATUS")))
b.WriteString("\n")
if len(m.servers) == 0 {
b.WriteString(helpStyle.Render(" No servers yet. Press Ctrl+A to add one."))
b.WriteString("\n")
} else {
selectedIndex := m.list.Index()
start, end := visibleServerRange(len(m.servers), selectedIndex, m.visibleServerRows())
for _, server := range m.servers[start:end] {
marker := " "
rowStyle := normalStyle
if server.Alias == selectedAlias {
marker = ">"
rowStyle = selectedRowStyle
}
if m.selected[server.Alias] {
marker = "*"
if server.Alias == selectedAlias {
marker = ">*"
}
}
name := server.DisplayName
if name == "" {
name = server.Alias
}
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
routeStr := server.Route.DisplaySummary(target)
// Add visual icon prefix based on connection type
if len(server.Route.Hops) == 0 {
routeStr = " " + routeStr // direct
} else {
routeStr = "→ " + routeStr // via/chain
}
// If too long, collapse middle hops
if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
first := server.Route.Hops[0]
firstName := first.Alias
if !first.IsProfile {
firstName = first.Raw
}
routeStr = fmt.Sprintf("→ %s → … → %s", firstName, truncate(target, 34-len(firstName)-8))
}
group := server.GroupName
if group == "" {
group = "-"
}
row := fmt.Sprintf("%s %-20s %-20s %-34s %-12s %-10s %s",
marker,
truncate(name, 20),
truncate(server.Alias, 20),
truncate(routeStr, 34),
authLabel(server.AuthMethod),
truncate(group, 10),
testStatusLabel(server),
)
b.WriteString(rowStyle.Render(row))
b.WriteString("\n")
}
if len(m.servers) > end-start {
b.WriteString(helpStyle.Render(fmt.Sprintf(" Showing %d-%d of %d", start+1, end, len(m.servers))))
b.WriteString("\n")
}
}
b.WriteString("\n")
if selectedAlias != "" {
if selected := m.selectedServer(); selected != nil {
b.WriteString(m.viewSelectedServer(selected))
b.WriteString("\n")
}
}
if len(m.bgResults) > 0 {
b.WriteString(m.viewInlineBackgroundResults())
b.WriteString("\n")
}
selectedCount := len(m.selectedServers())
footer := m.renderListHelp(selectedCount, len(m.bgResults) > 0)
b.WriteString(strings.Repeat("\n", bottomPaddingLines(b.String(), footer, m.height)))
b.WriteString(footer)
return b.String()
} }
func (m *tuiModel) viewInlineBackgroundResults() string { func (m *tuiModel) viewInlineBackgroundResults() string {
@ -2232,10 +2142,7 @@ func displayLineCount(s string) int {
} }
func truncate(s string, maxLen int) string { func truncate(s string, maxLen int) string {
if len(s) <= maxLen { return truncateCells(s, maxLen)
return s
}
return s[:maxLen-3] + "..."
} }
func splitCSV(value string) []string { func splitCSV(value string) []string {

307
internal/tui/dashboard.go Normal file
View File

@ -0,0 +1,307 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model"
)
func (m *tuiModel) renderServerDashboard() string {
width, height := m.width, m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 40
}
header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width)
footer := m.renderListHelp(len(m.selectedServers()), len(m.bgResults) > 0)
headerHeight := displayLineCount(header)
notificationHeight := displayLineCount(notification)
footerHeight := displayLineCount(footer)
bodyHeight := height - headerHeight - notificationHeight - footerHeight
if bodyHeight < 5 {
bodyHeight = 5
}
var body string
switch classifyTerminal(width, height) {
case sizeWide:
leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1
left := m.renderServerPanel(leftWidth, bodyHeight, true)
right := m.renderSelectedPanel(rightWidth, bodyHeight)
body = joinPanelColumns(left, leftWidth, right, rightWidth)
case sizeMedium:
detailsHeight := 6
listHeight := bodyHeight - detailsHeight
if listHeight < 5 {
listHeight = 5
}
body = m.renderServerPanel(width, listHeight, false)
if selected := m.selectedServer(); selected != nil && listHeight+detailsHeight <= bodyHeight {
body += "\n" + m.renderCompactSelected(selected, width, detailsHeight-1)
}
default:
body = m.renderServerPanel(width, bodyHeight, false)
}
view := header + notification + body
padding := height - displayLineCount(view) - footerHeight
if padding > 0 {
view += strings.Repeat("\n", padding)
}
return view + "\n" + footer
}
func (m *tuiModel) renderDashboardNotification(width int) string {
if m.err != nil {
return fitLine(errorStyle.Render("Error: "+m.err.Error()), width) + "\n"
}
if m.success != "" {
return fitLine(successStyle.Render(m.success), width) + "\n"
}
return ""
}
func (m *tuiModel) renderDashboardHeader(width int) string {
left := "sshkeeper / Servers"
vault := "Vault locked"
if m.vaultUnlocked {
vault = "Vault unlocked"
}
right := fmt.Sprintf("%s · %d profiles", vault, len(m.servers))
if selected := len(m.selectedServers()); selected > 0 {
right += fmt.Sprintf(" · %d selected", selected)
}
line := left + " " + right
if lipgloss.Width(left)+lipgloss.Width(right)+1 <= width {
line = left + strings.Repeat(" ", width-lipgloss.Width(left)-lipgloss.Width(right)) + right
}
headerStyle := titleStyle.Copy().MarginLeft(0)
separatorStyle := helpStyle.Copy().MarginLeft(0)
return headerStyle.Render(fitLine(line, width)) + "\n" + separatorStyle.Render(strings.Repeat("─", width)) + "\n"
}
func (m *tuiModel) renderServerPanel(width, height int, showTarget bool) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
lines = append(lines, listHeaderStyle.Render(fitLine(fmt.Sprintf("%d servers", len(m.servers)), innerWidth)))
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, nil, true))
rowCapacity := max(0, innerHeight-len(lines))
showRange := len(m.servers) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
if len(m.servers) == 0 {
lines = append(lines, helpStyle.Render(fitLine("No servers yet. Ctrl+A adds the first profile.", innerWidth)))
} else if rowCapacity > 0 {
start, end := visibleServerRange(len(m.servers), m.list.Index(), rowCapacity)
selected := m.selectedServer()
for _, server := range m.servers[start:end] {
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, server, selected != nil && server.Alias == selected.Alias))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.servers))))
}
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) renderServerColumns(width int, showTarget bool, server *model.Server, selected bool) string {
marker, name, target, auth, group, status := "", "NAME", "TARGET / ROUTE", "AUTH", "GROUP", "STATUS"
style := normalStyle
if server != nil {
marker = " "
if selected {
marker = ">"
style = selectedRowStyle
}
if m.selected[server.Alias] {
marker = "*"
if selected {
marker = ">*"
}
}
name = server.DisplayName
if name == "" {
name = server.Alias
}
target = fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
target = server.Route.DisplaySummary(target)
}
auth = authLabel(server.AuthMethod)
group = server.GroupName
if group == "" {
group = "-"
}
status = testStatusLabel(server)
}
markerWidth, authWidth, groupWidth, statusWidth := 2, 10, 10, 7
nameWidth := width - markerWidth - authWidth - groupWidth - statusWidth - 4
if showTarget {
nameWidth = min(18, max(10, nameWidth/3))
targetWidth := width - markerWidth - nameWidth - authWidth - groupWidth - statusWidth - 5
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(target, targetWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
func (m *tuiModel) renderSelectedPanel(width, height int) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
selected := m.selectedServer()
if selected == nil {
lines = append(lines, dashboardSection("Selected profile"), dashboardHelp("No profile selected."))
} else {
target := fmt.Sprintf("%s@%s:%d", selected.User, selected.Host, selected.Port)
route := "direct"
if len(selected.Route.Hops) > 0 {
route = selected.Route.DisplaySummary(target)
}
group := selected.GroupName
if group == "" {
group = "-"
}
lines = append(lines,
dashboardSection("Selected profile"),
fitLine("Alias: "+selected.Alias, innerWidth),
fitLine("Display Name: "+selected.DisplayName, innerWidth),
fitLine("Host: "+selected.Host, innerWidth),
fitLine(fmt.Sprintf("Port: %d User: %s", selected.Port, selected.User), innerWidth),
fitLine(target, innerWidth),
fitLine("Route "+route, innerWidth),
fitLine("Group "+group, innerWidth),
fitLine("Tags "+strings.Join(selected.Tags, ", "), innerWidth),
fitLine("Last test "+testStatusLabel(selected), innerWidth),
"",
dashboardSection("Primary actions"),
"Enter Connect",
"Ctrl+X More actions…",
)
lines = append(lines, m.backgroundPanelLines(selected.Alias, innerWidth)...)
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) backgroundPanelLines(alias string, width int) []string {
if len(m.bgResults) == 0 {
return nil
}
lines := []string{"", dashboardSection("Last Background Run")}
for _, result := range m.bgResults {
status := "OK"
if result.Err != "" {
status = "FAIL"
}
lines = append(lines, fitLine(result.Alias+" "+status, width))
}
result := m.backgroundResultForAlias(alias)
if result == nil && len(m.bgResults) == 1 {
result = &m.bgResults[0]
}
if result != nil {
output := strings.TrimSpace(result.Output)
if output == "" {
output = result.Err
}
if output != "" {
lines = append(lines, dashboardHelp("Output: "+result.Alias))
for _, line := range strings.Split(output, "\n") {
lines = append(lines, fitLine(strings.ReplaceAll(line, "\t", " "), width))
}
}
}
return lines
}
func (m *tuiModel) renderCompactSelected(server *model.Server, width, height int) string {
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
group := server.GroupName
if group == "" {
group = "-"
}
lines := []string{
dashboardSection("Selected profile"),
fitLine("Alias: "+server.Alias+" Target: "+target, width),
fitLine("Auth: "+authLabel(server.AuthMethod)+" Group: "+group+" Status: "+testStatusLabel(server), width),
fitLine("Enter: Connect Ctrl+X: More actions…", width),
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
func dashboardSection(value string) string {
return sectionStyle.Copy().MarginTop(0).Render(value)
}
func dashboardHelp(value string) string {
return helpStyle.Copy().MarginLeft(0).Render(value)
}
func renderPanel(width, height int, lines []string) string {
if width < 2 || height < 2 {
return ""
}
innerWidth := width - 2
var b strings.Builder
b.WriteString("┌" + strings.Repeat("─", innerWidth) + "┐\n")
for row := 0; row < height-2; row++ {
line := ""
if row < len(lines) {
line = lines[row]
}
b.WriteString("│" + padCells(line, innerWidth) + "│\n")
}
b.WriteString("└" + strings.Repeat("─", innerWidth) + "┘")
return b.String()
}
func joinPanelColumns(left string, leftWidth int, right string, rightWidth int) string {
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
rows := max(len(leftLines), len(rightLines))
joined := make([]string, rows)
for row := 0; row < rows; row++ {
leftLine, rightLine := "", ""
if row < len(leftLines) {
leftLine = leftLines[row]
}
if row < len(rightLines) {
rightLine = rightLines[row]
}
joined[row] = padCells(leftLine, leftWidth) + " " + padCells(rightLine, rightWidth)
}
return strings.Join(joined, "\n")
}

View File

@ -566,130 +566,118 @@ func parsePort(value string) (int, error) {
} }
func (fm *formModel) View() string { func (fm *formModel) View() string {
var b strings.Builder
title := "Add Server" title := "Add Server"
if fm.edit { if fm.edit {
title = "Edit Server: " + fm.server.Alias title = "Edit Server: " + fm.server.Alias
} }
b.WriteString(titleStyle.Render(title)) footer := renderHelp([]helpItem{
b.WriteString("\n\n")
reserved := 9
available := fm.height - reserved
if available < 4 {
available = 4
}
numInputs := len(fm.inputs)
startIdx := 0
endIdx := numInputs
if numInputs > available {
focusInput := fm.focusIdx
if focusInput >= numInputs {
focusInput = numInputs - 1
}
startIdx = focusInput - available/2
if startIdx < 0 {
startIdx = 0
}
endIdx = startIdx + available
if endIdx > numInputs {
endIdx = numInputs
startIdx = endIdx - available
if startIdx < 0 {
startIdx = 0
}
}
}
if startIdx > 0 {
b.WriteString(helpStyle.Render(" ↑ more fields above\n"))
}
for i := startIdx; i < endIdx; i++ {
if section := formSectionTitle(i); section != "" {
b.WriteString(sectionStyle.Render(section))
b.WriteString("\n")
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 && !fm.showGroupList {
fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25)
}
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
if i == 5 && fm.showAuthList {
b.WriteString("\n" + renderDropdown(fm.authList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
if i == 8 && fm.showGroupList {
b.WriteString("\n" + renderDropdown(fm.groupList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
}
if endIdx < numInputs {
b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs)))
}
b.WriteString(fm.password.View())
b.WriteString("\n")
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if fm.testing {
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
} else if fm.saving {
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
} else if showResults {
if fm.testResult != "" {
b.WriteString("\n")
if fm.testOK {
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
} else {
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
}
b.WriteString("\n")
}
if fm.saved {
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
}
}
if fm.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
}
testBtn := "[ Test ]"
saveBtn := "[ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render(testBtn)
} else {
testBtn = normalStyle.Render(testBtn)
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render(saveBtn)
} else {
saveBtn = normalStyle.Render(saveBtn)
}
b.WriteString("\n" + sectionStyle.Render("Actions") + "\n")
b.WriteString(testBtn + " " + saveBtn + "\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"}, {Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"}, {Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, fm.width)) }, fm.width)
return b.String() if fm.showAuthList || fm.showGroupList {
var dropdown list.Model
fieldIndex := 8
if fm.showAuthList {
dropdown = fm.authList
fieldIndex = 5
} else {
dropdown = fm.groupList
}
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
}
status := fm.formStatusLine()
testBtn, saveBtn := " [ Test ]", " [ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render("> [ Test ]")
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render("> [ Save ]")
}
actions := fitLine(testBtn+" "+saveBtn, fm.width)
reserved := 1 + displayLineCount(footer) + 1
if status != "" {
reserved++
}
fieldRows := max(4, fm.height-reserved)
richLayout := fm.width >= 90 && fm.height >= 24
allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs {
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
}
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
}
focusRows[i] = len(allFields)
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
}
focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx]
}
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 {
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
}
if end < len(allFields) && len(visible) > 0 {
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
}
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines, visible...)
if status != "" {
lines = append(lines, fitLine(status, fm.width))
}
if richLayout {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
lines = append(lines, actions)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
}
func (fm *formModel) formStatusLine() string {
if fm.err != nil {
return errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
}
if fm.testing {
return fm.spinner.View() + " Testing connection..."
}
if fm.saving {
return fm.spinner.View() + " Saving..."
}
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if showResults && fm.testResult != "" {
if fm.testOK {
return testOKStyle.Render("✓ " + fm.testResult)
}
return testFailStyle.Render("✗ " + strings.ReplaceAll(fm.testResult, "\n", " "))
}
if showResults && fm.saved {
return successStyle.Render("✓ Saved.")
}
return ""
} }
func renderDropdown(l list.Model) string { func renderDropdown(l list.Model) string {

View File

@ -79,69 +79,93 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
} }
func (m *forwardScreenModel) View() string { func (m *forwardScreenModel) View() string {
var b strings.Builder footer := renderHelp([]helpItem{
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
b.WriteString("\n\n")
if len(m.list) == 0 {
b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one."))
b.WriteString("\n")
} else {
// Column header
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
"NAME", "TYPE", "LISTEN", "TARGET", "ON")))
b.WriteString("\n")
for i, f := range m.list {
name := f.Name
if name == "" {
name = f.ForwardListen()
}
enabled := "yes"
if !f.Enabled {
enabled = "no"
}
line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
truncate(name, 22),
f.Type,
truncate(f.ForwardListen(), 20),
truncate(f.ForwardTarget(), 20),
enabled,
)
style := normalStyle
if i == m.selected {
style = selectedRowStyle
}
b.WriteString(style.Render(line))
b.WriteString("\n")
}
// Details for selected
if m.selected >= 0 && m.selected < len(m.list) {
f := m.list[m.selected]
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Selected"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias)))
for _, arg := range f.ForwardSSHArgs() {
b.WriteString(fmt.Sprintf(" %s\n", arg))
}
}
}
b.WriteString("\n")
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"}, {Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"}, {Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, m.width)) }, m.width)
return b.String() lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
if m.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
}
footerRows := displayLineCount(footer)
detailRows := 0
if len(m.list) > 0 && m.height-footerRows >= 7 {
detailRows = 3
}
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
if len(m.list) == 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} else {
lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity--
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
}
if end < len(m.list) || start > 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected]
lines = append(lines,
sectionStyle.Copy().MarginTop(0).Render("Selected"),
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
)
}
}
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil {
if selected {
marker = ">"
}
name = forward.Name
if name == "" {
name = forward.ForwardListen()
}
kind = string(forward.Type)
listen = forward.ForwardListen()
target = forward.ForwardTarget()
enabled = "yes"
if !forward.Enabled {
enabled = "no"
}
}
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3
if wide {
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, m.width)
}
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, m.width)
} }
// --- Forward form screen model --- // --- Forward form screen model ---
@ -522,64 +546,43 @@ func parseNamedPort(label, value string) (int, error) {
} }
func (fm *forwardFormModel) View() string { func (fm *forwardFormModel) View() string {
var b strings.Builder
title := "Add Port Forward" title := "Add Port Forward"
if fm.editMode { if fm.editMode {
title = "Edit Port Forward" title = "Edit Port Forward"
} }
b.WriteString(titleStyle.Render(title)) lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
b.WriteString("\n\n") lines = append(lines,
fitLine(fm.nameInput.View(), fm.width),
fitLine(fm.descInput.View(), fm.width),
)
// Name typeParts := make([]string, len(forwardTypes))
b.WriteString(fm.nameInput.View()) for i, forwardType := range forwardTypes {
b.WriteString("\n") selected := "○"
// Description
b.WriteString(fm.descInput.View())
b.WriteString("\n\n")
// Type selector — visible radio items with descriptions
b.WriteString(sectionStyle.Render("Type"))
b.WriteString("\n")
for i, t := range forwardTypes {
prefix := " "
style := normalStyle
if i == fm.typeIdx { if i == fm.typeIdx {
prefix = "▸ " selected = "●"
style = selectedRowStyle
} }
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description) focus := " "
b.WriteString(style.Render(line)) if fm.focusIdx == 2+i {
b.WriteString("\n") focus = ">"
} }
// Show human-readable explanation for selected type typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
explanations := map[model.ForwardType]string{
model.ForwardLocal: "Opens a local port on this machine and forwards it through SSH to the target address.",
model.ForwardRemote: "Opens a port on the remote SSH server and forwards it back to this machine.",
model.ForwardDynamic: "Creates a local SOCKS proxy that routes all traffic through the SSH server.",
} }
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok { lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp))) if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
} }
}
b.WriteString("\n")
// Dynamic fields based on type
visible := fm.visibleFields() visible := fm.visibleFields()
for _, idx := range visible { for _, idx := range visible {
b.WriteString(fm.inputs[idx].View()) lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
b.WriteString("\n")
} }
// Warning for 0.0.0.0
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" { if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n")) lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
} }
// Preview if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
if fm.currentType != "" && fm.inputs[1].Value() != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
fwd := &model.Forward{ fwd := &model.Forward{
Type: fm.currentType, Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(), LocalAddr: fm.inputs[0].Value(),
@ -589,37 +592,34 @@ func (fm *forwardFormModel) View() string {
} }
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort) fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort) fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
for _, arg := range fwd.ForwardSSHArgs() { preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
b.WriteString(" " + arg + "\n") lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
}
b.WriteString(" -o ExitOnForwardFailure=yes\n")
} }
// Save button
total := 2 + 3 + len(visible) + 1 total := 2 + 3 + len(visible) + 1
button := "\n[ Save ]" button := " [ Save ]"
if fm.focusIdx == total-1 { if fm.focusIdx == total-1 {
button = selectedStyle.Render(button) button = selectedStyle.Render("> [ Save ]")
} }
b.WriteString(button)
b.WriteString("\n\n")
if fm.err != nil { if fm.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n") lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
} }
if fm.saved { if fm.saved {
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n") lines = append(lines, successStyle.Render("✓ Saved."))
} }
lines = append(lines, button)
b.WriteString(renderHelp([]helpItem{ footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"}, {Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"}, {Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, fm.width)) }, fm.width)
lines = append(lines, strings.Split(footer, "\n")...)
return b.String() if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
} }
// forwardEditSignal is sent when user wants to edit a forward // forwardEditSignal is sent when user wants to edit a forward

View File

@ -274,6 +274,7 @@ func (i actionMenuItem) FilterValue() string { return i.label }
type actionMenuModel struct { type actionMenuModel struct {
list list.Model list list.Model
width int width int
height int
} }
func newActionMenuModel(w, h int) *actionMenuModel { func newActionMenuModel(w, h int) *actionMenuModel {
@ -301,7 +302,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
l.SetShowHelp(false) l.SetShowHelp(false)
l.Styles.Title = titleStyle l.Styles.Title = titleStyle
return &actionMenuModel{list: l, width: w} return &actionMenuModel{list: l, width: w, height: h}
} }
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) { func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
@ -323,5 +324,24 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
} }
func (m *actionMenuModel) View() string { func (m *actionMenuModel) View() string {
return m.list.View() footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
capacity := max(1, m.height-displayLineCount(footer)-1)
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
for index := start; index < end; index++ {
item, ok := m.list.Items()[index].(actionMenuItem)
if !ok {
continue
}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, fitLine(marker+item.label, m.width))
}
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
} }

68
internal/tui/layout.go Normal file
View File

@ -0,0 +1,68 @@
package tui
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
const (
minimumTUIWidth = 60
minimumTUIHeight = 16
)
type terminalSizeClass int
const (
sizeBelowFloor terminalSizeClass = iota
sizeNarrow
sizeMedium
sizeWide
)
func classifyTerminal(width, height int) terminalSizeClass {
if width > 0 && height > 0 && (width < minimumTUIWidth || height < minimumTUIHeight) {
return sizeBelowFloor
}
if width >= 100 {
return sizeWide
}
if width >= 70 {
return sizeMedium
}
return sizeNarrow
}
func truncateCells(value string, width int) string {
if width <= 0 {
return ""
}
if ansi.StringWidth(value) <= width {
return value
}
if width == 1 {
return "…"
}
return ansi.Truncate(value, width, "…")
}
func padCells(value string, width int) string {
value = truncateCells(value, width)
missing := width - ansi.StringWidth(value)
if missing > 0 {
value += strings.Repeat(" ", missing)
}
return value
}
func fitLine(value string, width int) string {
return truncateCells(value, width)
}
func minimumSizeView(width int) string {
message := "sshkeeper needs at least 60x16"
if width <= 0 {
return message
}
return truncateCells(message, width)
}

189
internal/tui/layout_test.go Normal file
View File

@ -0,0 +1,189 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestTruncateCellsHandlesUnicodeDisplayWidth(t *testing.T) {
tests := []string{
"production-сервер",
"数据库服务器",
"e\u0301-combining",
"🔐 gateway",
}
for _, value := range tests {
got := truncateCells(value, 8)
if width := ansi.StringWidth(got); width > 8 {
t.Fatalf("truncateCells(%q) width=%d result=%q", value, width, got)
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("truncated value has no indicator: %q", got)
}
}
}
func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
servers := []*model.Server{
{
Alias: "staging-数据库-bastion",
DisplayName: "Production сервер 🔐 with a very long display name",
Host: "bastion.staging.example.net",
Port: 2222,
User: "operations",
AuthMethod: model.AuthAgent,
GroupName: "STAGING-LONG",
Tags: []string{"stage", "bastion", "кириллица"},
},
{Alias: "db", DisplayName: "Database", Host: "db.internal", Port: 22, User: "postgres", AuthMethod: model.AuthKeyPassphrase},
}
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(strings.Join([]string{itoa(size.width), "x", itoa(size.height)}, ""), func(t *testing.T) {
m := New(servers)
m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height)
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())
}
}
})
}
}
func TestDashboardNotificationFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops", AuthMethod: model.AuthAgent}})
m.width, m.height = size.width, size.height
m.err = errText("vault reload failed and this message must remain visible")
view := m.View()
assertViewFits(t, view, size.width, size.height)
if !strings.Contains(view, "vault reload failed") {
t.Fatalf("dashboard at %dx%d lost notification:\n%s", size.width, size.height, view)
}
}
}
func TestScreensBelowSupportedFloorShowSizeMessage(t *testing.T) {
m := New(nil)
m.width, m.height = 59, 15
view := m.View()
if !strings.Contains(view, "60x16") {
t.Fatalf("missing supported-size message:\n%s", view)
}
assertViewFits(t, view, 59, 15)
}
func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(itoa(size.width), func(t *testing.T) {
fm := newFormModel(size.width, size.height)
fm.inputs[0].SetValue("prod")
fm.inputs[2].SetValue("prod.example")
fm.inputs[3].SetValue("not-a-port")
fm.err = errText("Port must be a number from 1 to 65535")
fm.focusIdx = 3
fm.updateFocus()
view := fm.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
})
}
}
func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
fm := newForwardFormModel(1, size.width, size.height)
fm.nameInput.SetValue("Local PostgreSQL")
fm.inputs[0].SetValue("127.0.0.1")
fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432")
assertViewFits(t, fm.View(), size.width, size.height)
}
}
func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
fm := newForwardScreenModel(1, "production-数据库-bastion", size.width, size.height)
fm.list = []*model.Forward{
{Name: "Local PostgreSQL with a very long name", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "database.internal.example", RemotePort: 5432, Enabled: true},
{Name: "SOCKS proxy", Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080, Enabled: false},
}
view := fm.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
menu := newActionMenuModel(size.width, size.height)
view := menu.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, 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)
form.inputs[0].SetValue("проверка-数据库")
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
if len(lines) > height {
t.Fatalf("view has %d lines, terminal height is %d:\n%s", len(lines), height, view)
}
for index, line := range lines {
if lineWidth := ansi.StringWidth(line); lineWidth > width {
t.Fatalf("line %d has width %d, terminal width is %d: %q", index+1, lineWidth, width, ansi.Strip(line))
}
}
}
type errText string
func (e errText) Error() string { return string(e) }
func itoa(value int) string {
const digits = "0123456789"
if value == 0 {
return "0"
}
var out [20]byte
index := len(out)
for value > 0 {
index--
out[index] = digits[value%10]
value /= 10
}
return string(out[index:])
}

View File

@ -156,15 +156,15 @@ func (tf *templateFormModel) View() string {
if tf.edit { if tf.edit {
title = "Edit Template" title = "Edit Template"
} }
b.WriteString(titleStyle.Render(title)) b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
b.WriteString("\n\n") b.WriteString("\n\n")
for i := range tf.inputs { for i := range tf.inputs {
b.WriteString(tf.inputs[i].View()) b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
b.WriteString("\n") b.WriteString("\n")
} }
button := "[ Save ]" button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) { if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render(button) button = selectedStyle.Render("> [ Save ]")
} }
b.WriteString("\n" + button + "\n\n") b.WriteString("\n" + button + "\n\n")
if tf.err != nil { if tf.err != nil {