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
+4 -3
View File
@@ -16,14 +16,15 @@
- **sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up` - **sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up`
- **form tabs footer**: Added `Shift+Tab:prev`, `↑↓:nav` - **form tabs footer**: Added `Shift+Tab:prev`, `↑↓:nav`
### Responsive Layout (In Progress) ### Responsive Layout (Done)
- **NEW `responsive.go`**: Shared helpers (`wrapFooter`, `adaptiveSidePad`, `clampWidth`, `truncateStr`) - **NEW `responsive.go`**: Shared helpers (`wrapFooter`, `adaptiveSidePad`, `clampWidth`, `truncateStr`)
- Footer auto-wraps to multi-line when terminal is narrow (full labels preserved) - Footer auto-wraps to multi-line when terminal is narrow (full labels preserved)
- Box width clamped to terminal width — no more overflow - Box width clamped to terminal width — no more overflow
- Host/key/snippet rows truncated with `…` when names are too long - Host/key/snippet rows truncated with `…` when names are too long; compact row format on narrow screens
- SFTP panes stack vertically when terminal < 50 cols - SFTP panes stack vertically when terminal < 50 cols
- Tab bar truncates tab names on overflow - Tab bar truncates tab names on overflow
- Form contentW minimum lowered to 30 for mobile (Termux) - Form contentW minimum lowered from 50 to 30 for mobile (Termux)
- Fixed: key_list_tab & snippet_list_tab were dropping last data row (off-by-one in `rows[:len(rows)-1]`)
- Breakpoints: compact (<60), medium (60100), wide (≥100) - Breakpoints: compact (<60), medium (60100), wide (≥100)
## v1.0.0 (2025-01-30) ## v1.0.0 (2025-01-30)
+10 -9
View File
@@ -52,15 +52,16 @@
**sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up` **sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up`
**Form tab footers**: Added `Shift+Tab:prev`, `↑↓:nav` **Form tab footers**: Added `Shift+Tab:prev`, `↑↓:nav`
### In Progress: Responsive Layout Overhaul ### Done: Responsive Layout Overhaul
🔄 **NEW `responsive.go`**: Shared helpers for adaptive layout (`wrapFooter`, `adaptiveSidePad`, `clampWidth`, `truncateStr`) **NEW `responsive.go`**: Shared helpers for adaptive layout (`wrapFooter`, `adaptiveSidePad`, `clampWidth`, `truncateStr`)
🔄 Footer auto-wraps to multi-line (full labels preserved) — no more overflow on narrow terminals Footer auto-wraps to multi-line (full labels preserved) — no more overflow on narrow terminals
🔄 Box width clamped to terminal width across all tabs Box width clamped to terminal width across all tabs
🔄 Host/key/snippet rows truncated with `…` for long names Host/key/snippet rows truncated with `…` for long names; compact format on narrow screens
🔄 SFTP panes stack vertically when terminal < 50 cols SFTP panes stack vertically when terminal < 50 cols
🔄 Tab bar truncates names on overflow Tab bar truncates names on overflow
🔄 Form contentW minimum lowered from 50 to 30 for mobile (Termux) Form contentW minimum lowered from 50 to 30 for mobile (Termux)
🔄 Breakpoints: compact (<60 cols / mobile), medium (60100 / tablet), wide (≥100 / desktop) ✅ Fixed bug: key_list_tab.go & snippet_list_tab.go dropped last row (`rows[:len(rows)-1]` excluded real data)
✅ Breakpoints: compact (<60 cols / mobile), medium (60100 / tablet), wide (≥100 / desktop)
--- ---
+8 -4
View File
@@ -293,8 +293,8 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) {
func (t *HostFormTab) View() string { func (t *HostFormTab) View() string {
contentW := t.width - 12 contentW := t.width - 12
if contentW < 50 { if contentW < 30 {
contentW = 50 contentW = 30
} }
if contentW > 70 { if contentW > 70 {
contentW = 70 contentW = 70
@@ -358,8 +358,12 @@ func (t *HostFormTab) View() string {
} }
inner.WriteString("\n") inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"))) footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String()) box := BorderStyle.Render(inner.String())
var b strings.Builder var b strings.Builder
+45 -41
View File
@@ -104,7 +104,7 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
return t, nil return t, nil
} }
// View renders the host list — exactly like tamagosh // View renders the host list — responsive layout
func (t *HostListTab) View() string { func (t *HostListTab) View() string {
var b strings.Builder var b strings.Builder
@@ -121,13 +121,6 @@ func (t *HostListTab) View() string {
return b.String() return b.String()
} }
// Build rows (plain + styled)
type styledRow struct {
text string
plain string
}
var rows []styledRow
// Scrolling // Scrolling
availH := t.height - 10 availH := t.height - 10
if availH < 1 { if availH < 1 {
@@ -146,11 +139,45 @@ func (t *HostListTab) View() string {
start = len(t.hosts) - maxHosts start = len(t.hosts) - maxHosts
} }
// Responsive width calculation
sidePad := adaptiveSidePad(t.width)
titlePlain := "Connection List"
// Measure content rows to determine natural box width
widestContent := lipgloss.Width(titlePlain)
for i := start; i < start+maxHosts; i++ { for i := start; i < start+maxHosts; i++ {
host := t.hosts[i] host := t.hosts[i]
// tamagosh format: " %-12s %-18s :%d"
raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port) raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
if w := lipgloss.Width(raw); w > widestContent {
widestContent = w
}
}
// Clamp box to terminal width
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Build rows with adaptive format
type styledRow struct {
text string
plain string
}
var rows []styledRow
for i := start; i < start+maxHosts; i++ {
host := t.hosts[i]
var raw string
if innerW >= 35 {
raw = fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
} else {
raw = fmt.Sprintf(" %s %s:%d", host.Name, host.Hostname, host.Port)
}
if lipgloss.Width(raw) > innerW {
raw = truncateStr(raw, innerW)
}
var styled string var styled string
if i == t.selectedIndex { if i == t.selectedIndex {
@@ -165,54 +192,31 @@ func (t *HostListTab) View() string {
rows = append(rows, styledRow{text: styled, plain: raw}) rows = append(rows, styledRow{text: styled, plain: raw})
} }
// Footer // Footer (wrapped to fit innerW)
var footer styledRow
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+E:edit Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+E:edit Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
footer = styledRow{text: SubtitleStyle.Render(footerText), plain: footerText} footerWrapped := wrapFooter(footerText, innerW)
rows = append(rows, footer) // for widest measurement
// Title // Title
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render("Connection List") title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
titlePlain := "Connection List"
// tamagosh: widest content line drives the box's natural width
widestRow := 0
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestRow {
widestRow = w
}
}
contentW := widestRow
if w := lipgloss.Width(footer.plain); w > contentW {
contentW = w
}
if w := lipgloss.Width(titlePlain); w > contentW {
contentW = w
}
const sidePad = 6
targetW := contentW + sidePad*2
var content strings.Builder var content strings.Builder
// Centered title
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title)) content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
content.WriteString("\n\n") content.WriteString("\n\n")
// Center each row independently within the box for _, r := range rows {
for _, r := range rows[:len(rows)-1] { // exclude footer, added below
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text) line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
content.WriteString(line) content.WriteString(line)
content.WriteString("\n") content.WriteString("\n")
} }
content.WriteString("\n") content.WriteString("\n")
// Centered footer for _, line := range strings.Split(footerWrapped, "\n") {
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text)) content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
content.WriteString("\n")
}
// Wrap in a rounded border box
box := BorderStyle.Render(content.String()) box := BorderStyle.Render(content.String())
// Center the whole box horizontally
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String() return b.String()
+8 -4
View File
@@ -198,8 +198,8 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
func (t *KeyFormTab) View() string { func (t *KeyFormTab) View() string {
contentW := t.width - 12 contentW := t.width - 12
if contentW < 50 { if contentW < 30 {
contentW = 50 contentW = 30
} }
if contentW > 70 { if contentW > 70 {
contentW = 70 contentW = 70
@@ -235,8 +235,12 @@ func (t *KeyFormTab) View() string {
} }
inner.WriteString("\n") inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"))) footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String()) box := BorderStyle.Render(inner.String())
var b strings.Builder var b strings.Builder
+32 -19
View File
@@ -153,11 +153,11 @@ func (t *KeyListTab) View() string {
text string text string
plain string plain string
} }
titlePlain := "SSH Key Pairs"
// Build content rows
var rows []styledRow var rows []styledRow
title := "SSH Key Pairs"
titlePlain := title
if t.err != nil { if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err) errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine}) rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
@@ -190,36 +190,49 @@ func (t *KeyListTab) View() string {
} }
} }
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back" // Responsive width
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain} sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
widestRow := lipgloss.Width(titlePlain)
for _, r := range rows { for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestRow { if w := lipgloss.Width(r.plain); w > widestContent {
widestRow = w widestContent = w
} }
} }
if w := lipgloss.Width(footer.plain); w > widestRow { targetW := clampWidth(widestContent+sidePad*2, t.width)
widestRow = w innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
} }
const sidePad = 6 // Footer (wrapped)
targetW := widestRow + sidePad*2 footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain) titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled)) inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n") inner.WriteString("\n\n")
// Render rows, excluding the footer (last element) for _, r := range rows {
for _, r := range rows[:len(rows)-1] { plain := r.plain
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text) if lipgloss.Width(plain) > innerW {
plain = truncateStr(plain, innerW)
}
styled := r.text
if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line) inner.WriteString(line)
inner.WriteString("\n") inner.WriteString("\n")
} }
inner.WriteString("\n") inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String()) box := BorderStyle.Render(inner.String())
var b strings.Builder var b strings.Builder
+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) + "…"
}
+21 -5
View File
@@ -540,13 +540,21 @@ func (t *SFTPBrowserTab) View() string {
SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))) SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name)))
} }
// Responsive pane layout: stack vertically on narrow terminals
var leftView, rightView string
if t.width < 50 {
// Stack vertically — each pane gets full width
leftView = t.renderPane(&t.left, paneLocal, t.width)
rightView = t.renderPane(&t.right, paneRemote, t.width)
} else {
// Side by side — each pane gets half width
halfW := t.width / 2 halfW := t.width / 2
if halfW < 20 { if halfW < 20 {
halfW = 20 halfW = 20
} }
leftView = t.renderPane(&t.left, paneLocal, halfW)
leftView := t.renderPane(&t.left, paneLocal, halfW) rightView = t.renderPane(&t.right, paneRemote, halfW)
rightView := t.renderPane(&t.right, paneRemote, halfW) }
var b strings.Builder var b strings.Builder
@@ -554,7 +562,11 @@ func (t *SFTPBrowserTab) View() string {
AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname)))) AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))))
b.WriteString("\n") b.WriteString("\n")
if t.width < 50 {
b.WriteString(lipgloss.JoinVertical(lipgloss.Left, leftView, rightView))
} else {
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)) b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
}
if t.err != nil { if t.err != nil {
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
@@ -566,8 +578,12 @@ func (t *SFTPBrowserTab) View() string {
InfoStyle.Render(t.transferMsg))) InfoStyle.Render(t.transferMsg)))
} }
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, // Footer (wrapped to terminal width)
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav Enter/→:open ←/Backspace:up /:filter C:copy D:delete N:mkdir R:refresh Esc:close"))) footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav Enter/→:open ←/Backspace:up /:filter C:copy D:delete N:mkdir R:refresh Esc:close"
footerWrapped := wrapFooter(footerText, t.width)
for _, line := range strings.Split(footerWrapped, "\n") {
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, SubtitleStyle.Render(line)))
}
return b.String() return b.String()
} }
+8 -4
View File
@@ -206,8 +206,8 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
func (t *SnippetFormTab) View() string { func (t *SnippetFormTab) View() string {
contentW := t.width - 12 contentW := t.width - 12
if contentW < 50 { if contentW < 30 {
contentW = 50 contentW = 30
} }
if contentW > 70 { if contentW > 70 {
contentW = 70 contentW = 70
@@ -243,8 +243,12 @@ func (t *SnippetFormTab) View() string {
} }
inner.WriteString("\n") inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"))) footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String()) box := BorderStyle.Render(inner.String())
var b strings.Builder var b strings.Builder
+27 -19
View File
@@ -153,11 +153,10 @@ func (t *SnippetListTab) View() string {
text string text string
plain string plain string
} }
titlePlain := "Command Snippets"
var rows []styledRow var rows []styledRow
title := "Command Snippets"
titlePlain := title
if t.err != nil { if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err) errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine}) rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
@@ -190,36 +189,45 @@ func (t *SnippetListTab) View() string {
} }
} }
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back" // Responsive width
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain} sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
widestRow := lipgloss.Width(titlePlain)
for _, r := range rows { for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestRow { if w := lipgloss.Width(r.plain); w > widestContent {
widestRow = w widestContent = w
} }
} }
if w := lipgloss.Width(footer.plain); w > widestRow { targetW := clampWidth(widestContent+sidePad*2, t.width)
widestRow = w innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
} }
const sidePad = 6 // Footer (wrapped)
targetW := widestRow + sidePad*2 footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain) titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled)) inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n") inner.WriteString("\n\n")
// Render rows, excluding the footer (last element) for _, r := range rows {
for _, r := range rows[:len(rows)-1] { styled := r.text
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text) if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line) inner.WriteString(line)
inner.WriteString("\n") inner.WriteString("\n")
} }
inner.WriteString("\n") inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String()) box := BorderStyle.Render(inner.String())
var b strings.Builder var b strings.Builder
+27 -1
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
// Tab represents a single tab in the TUI // Tab represents a single tab in the TUI
@@ -182,15 +183,40 @@ func (tm *TabManager) View() string {
return b.String() return b.String()
} }
// renderTabBar renders the top tab bar // renderTabBar renders the top tab bar (responsive: truncates names on overflow)
func renderTabBar(tm *TabManager) string { func renderTabBar(tm *TabManager) string {
if len(tm.tabs) == 0 { if len(tm.tabs) == 0 {
return "" return ""
} }
const cellPad = 4 // Padding(0,2) per tab = 2 left + 2 right
availW := tm.width - 2
if availW < 1 {
availW = 1
}
// Measure total width and decide if truncation is needed
totalW := 0
for _, tab := range tm.tabs {
totalW += lipgloss.Width(tab.Name()) + cellPad
}
maxNameW := 0
if totalW > availW {
perTab := availW / len(tm.tabs)
maxNameW = perTab - cellPad
if maxNameW < 1 {
maxNameW = 1
}
}
var cells []string var cells []string
for i, tab := range tm.tabs { for i, tab := range tm.tabs {
name := tab.Name() name := tab.Name()
if maxNameW > 0 && lipgloss.Width(name) > maxNameW {
name = truncateStr(name, maxNameW)
}
if i == tm.active { if i == tm.active {
cells = append(cells, TabActiveStyle.Render(name)) cells = append(cells, TabActiveStyle.Render(name))
} else { } else {