feat: responsive TUI layout — mobile/tablet/desktop support

- NEW responsive.go: wrapFooter, adaptiveSidePad, clampWidth, truncateStr
- Footer auto-wraps to multi-line on narrow terminals (full labels preserved)
- Box width clamped to terminal width across all tabs
- Host/key/snippet rows truncated with ellipsis; compact format <35 cols
- SFTP panes stack vertically when terminal < 50 cols
- Tab bar truncates names on overflow
- Form contentW minimum lowered 50->30 for mobile
- Fixed: key_list_tab & snippet_list_tab dropped last data row (off-by-one bug)
This commit is contained in:
swanadiva
2026-06-23 19:08:44 +07:00
parent d359688b75
commit d75621e9b4
11 changed files with 302 additions and 113 deletions
+108
View File
@@ -0,0 +1,108 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Responsive breakpoints
const (
widthCompact = 60 // mobile / Termux
widthMedium = 100 // tablet
)
// boxOverhead is the horizontal chars consumed by BorderStyle (border 2 + padding 4)
const boxOverhead = 6
// wrapFooter wraps a footer string into multiple lines that fit availW.
// Words are split on double-space separators (" ") and grouped greedily.
// Returns the wrapped string with "\n" line breaks.
func wrapFooter(text string, availW int) string {
if availW < 1 {
availW = 1
}
if lipgloss.Width(text) <= availW {
return text
}
words := strings.Split(text, " ")
var lines []string
var current strings.Builder
for _, word := range words {
word = strings.TrimSpace(word)
if word == "" {
continue
}
if current.Len() == 0 {
current.WriteString(word)
} else if current.Len()+2+lipgloss.Width(word) <= availW {
current.WriteString(" ")
current.WriteString(word)
} else {
lines = append(lines, current.String())
current.Reset()
current.WriteString(word)
}
}
if current.Len() > 0 {
lines = append(lines, current.String())
}
return strings.Join(lines, "\n")
}
// adaptiveSidePad returns horizontal padding based on terminal width.
// wide: 6, medium: 3, compact: 1
func adaptiveSidePad(termWidth int) int {
switch {
case termWidth < widthCompact:
return 1
case termWidth < widthMedium:
return 3
default:
return 6
}
}
// clampWidth clamps a target box width to fit within the terminal.
// Reserves boxOverhead for border+padding. Enforces a minimum of 20.
func clampWidth(target, termWidth int) int {
maxW := termWidth - boxOverhead
if maxW < 20 {
maxW = 20
}
if target > maxW {
return maxW
}
if target < 20 {
return 20
}
return target
}
// truncateStr truncates a string to maxLen with an ellipsis character.
func truncateStr(s string, maxLen int) string {
if maxLen < 1 {
return ""
}
if lipgloss.Width(s) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
runes := []rune(s)
var result []rune
resultW := 0
for _, r := range runes {
rw := lipgloss.Width(string(r))
if resultW+rw > maxLen-1 {
break
}
result = append(result, r)
resultW += rw
}
return string(result) + "…"
}