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:
+4
-3
@@ -16,14 +16,15 @@
|
||||
- **sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up`
|
||||
- **form tabs footer**: Added `Shift+Tab:prev`, `↑↓:nav`
|
||||
|
||||
### Responsive Layout (In Progress)
|
||||
### Responsive Layout (Done)
|
||||
- **NEW `responsive.go`**: Shared helpers (`wrapFooter`, `adaptiveSidePad`, `clampWidth`, `truncateStr`)
|
||||
- Footer auto-wraps to multi-line when terminal is narrow (full labels preserved)
|
||||
- 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
|
||||
- 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 (60–100), wide (≥100)
|
||||
|
||||
## v1.0.0 (2025-01-30)
|
||||
|
||||
+10
-9
@@ -52,15 +52,16 @@
|
||||
✅ **sftp_browser_tab.go footer**: Added `Enter/→:open`, `←/Backspace:up`
|
||||
✅ **Form tab footers**: Added `Shift+Tab:prev`, `↑↓:nav`
|
||||
|
||||
### In Progress: Responsive Layout Overhaul
|
||||
🔄 **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
|
||||
🔄 Box width clamped to terminal width across all tabs
|
||||
🔄 Host/key/snippet rows truncated with `…` for long names
|
||||
🔄 SFTP panes stack vertically when terminal < 50 cols
|
||||
🔄 Tab bar truncates names on overflow
|
||||
🔄 Form contentW minimum lowered from 50 to 30 for mobile (Termux)
|
||||
🔄 Breakpoints: compact (<60 cols / mobile), medium (60–100 / tablet), wide (≥100 / desktop)
|
||||
### Done: Responsive Layout Overhaul
|
||||
✅ **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
|
||||
✅ Box width clamped to terminal width across all tabs
|
||||
✅ Host/key/snippet rows truncated with `…` for long names; compact format on narrow screens
|
||||
✅ SFTP panes stack vertically when terminal < 50 cols
|
||||
✅ Tab bar truncates names on overflow
|
||||
✅ Form contentW minimum lowered from 50 to 30 for mobile (Termux)
|
||||
✅ 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 (60–100 / tablet), wide (≥100 / desktop)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,8 +293,8 @@ func (t *HostFormTab) submit() (Tab, tea.Cmd) {
|
||||
|
||||
func (t *HostFormTab) View() string {
|
||||
contentW := t.width - 12
|
||||
if contentW < 50 {
|
||||
contentW = 50
|
||||
if contentW < 30 {
|
||||
contentW = 30
|
||||
}
|
||||
if contentW > 70 {
|
||||
contentW = 70
|
||||
@@ -358,8 +358,12 @@ func (t *HostFormTab) View() string {
|
||||
}
|
||||
|
||||
inner.WriteString("\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel")))
|
||||
footerText := "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())
|
||||
var b strings.Builder
|
||||
|
||||
+45
-41
@@ -104,7 +104,7 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// View renders the host list — exactly like tamagosh
|
||||
// View renders the host list — responsive layout
|
||||
func (t *HostListTab) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -121,13 +121,6 @@ func (t *HostListTab) View() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Build rows (plain + styled)
|
||||
type styledRow struct {
|
||||
text string
|
||||
plain string
|
||||
}
|
||||
var rows []styledRow
|
||||
|
||||
// Scrolling
|
||||
availH := t.height - 10
|
||||
if availH < 1 {
|
||||
@@ -146,11 +139,45 @@ func (t *HostListTab) View() string {
|
||||
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++ {
|
||||
host := t.hosts[i]
|
||||
|
||||
// tamagosh format: " %-12s %-18s :%d"
|
||||
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
|
||||
if i == t.selectedIndex {
|
||||
@@ -165,54 +192,31 @@ func (t *HostListTab) View() string {
|
||||
rows = append(rows, styledRow{text: styled, plain: raw})
|
||||
}
|
||||
|
||||
// Footer
|
||||
var footer styledRow
|
||||
// Footer (wrapped to fit innerW)
|
||||
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}
|
||||
rows = append(rows, footer) // for widest measurement
|
||||
footerWrapped := wrapFooter(footerText, innerW)
|
||||
|
||||
// Title
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render("Connection List")
|
||||
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
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
// Centered title
|
||||
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Center each row independently within the box
|
||||
for _, r := range rows[:len(rows)-1] { // exclude footer, added below
|
||||
for _, r := range rows {
|
||||
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||
content.WriteString(line)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
content.WriteString("\n")
|
||||
|
||||
// Centered footer
|
||||
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
|
||||
for _, line := range strings.Split(footerWrapped, "\n") {
|
||||
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Wrap in a rounded border box
|
||||
box := BorderStyle.Render(content.String())
|
||||
|
||||
// Center the whole box horizontally
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
|
||||
|
||||
return b.String()
|
||||
|
||||
@@ -198,8 +198,8 @@ func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
|
||||
|
||||
func (t *KeyFormTab) View() string {
|
||||
contentW := t.width - 12
|
||||
if contentW < 50 {
|
||||
contentW = 50
|
||||
if contentW < 30 {
|
||||
contentW = 30
|
||||
}
|
||||
if contentW > 70 {
|
||||
contentW = 70
|
||||
@@ -235,8 +235,12 @@ func (t *KeyFormTab) View() string {
|
||||
}
|
||||
|
||||
inner.WriteString("\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel")))
|
||||
footerText := "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())
|
||||
var b strings.Builder
|
||||
|
||||
+32
-19
@@ -153,11 +153,11 @@ func (t *KeyListTab) View() string {
|
||||
text string
|
||||
plain string
|
||||
}
|
||||
|
||||
titlePlain := "SSH Key Pairs"
|
||||
|
||||
// Build content rows
|
||||
var rows []styledRow
|
||||
|
||||
title := "SSH Key Pairs"
|
||||
titlePlain := title
|
||||
|
||||
if t.err != nil {
|
||||
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||
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"
|
||||
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain}
|
||||
|
||||
widestRow := lipgloss.Width(titlePlain)
|
||||
// Responsive width
|
||||
sidePad := adaptiveSidePad(t.width)
|
||||
widestContent := lipgloss.Width(titlePlain)
|
||||
for _, r := range rows {
|
||||
if w := lipgloss.Width(r.plain); w > widestRow {
|
||||
widestRow = w
|
||||
if w := lipgloss.Width(r.plain); w > widestContent {
|
||||
widestContent = w
|
||||
}
|
||||
}
|
||||
if w := lipgloss.Width(footer.plain); w > widestRow {
|
||||
widestRow = w
|
||||
targetW := clampWidth(widestContent+sidePad*2, t.width)
|
||||
innerW := targetW - sidePad*2
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
|
||||
const sidePad = 6
|
||||
targetW := widestRow + sidePad*2
|
||||
// Footer (wrapped)
|
||||
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
|
||||
|
||||
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
|
||||
inner.WriteString("\n\n")
|
||||
|
||||
// Render rows, excluding the footer (last element)
|
||||
for _, r := range rows[:len(rows)-1] {
|
||||
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||
for _, r := range rows {
|
||||
plain := r.plain
|
||||
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("\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())
|
||||
var b strings.Builder
|
||||
|
||||
@@ -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) + "…"
|
||||
}
|
||||
@@ -540,21 +540,33 @@ func (t *SFTPBrowserTab) View() string {
|
||||
SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name)))
|
||||
}
|
||||
|
||||
halfW := t.width / 2
|
||||
if halfW < 20 {
|
||||
halfW = 20
|
||||
// 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
|
||||
if halfW < 20 {
|
||||
halfW = 20
|
||||
}
|
||||
leftView = t.renderPane(&t.left, paneLocal, halfW)
|
||||
rightView = t.renderPane(&t.right, paneRemote, halfW)
|
||||
}
|
||||
|
||||
leftView := t.renderPane(&t.left, paneLocal, halfW)
|
||||
rightView := t.renderPane(&t.right, paneRemote, halfW)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||
AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))))
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
||||
if t.width < 50 {
|
||||
b.WriteString(lipgloss.JoinVertical(lipgloss.Left, leftView, rightView))
|
||||
} else {
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
||||
}
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||
@@ -566,8 +578,12 @@ func (t *SFTPBrowserTab) View() string {
|
||||
InfoStyle.Render(t.transferMsg)))
|
||||
}
|
||||
|
||||
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
||||
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")))
|
||||
// Footer (wrapped to terminal width)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -206,8 +206,8 @@ func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
|
||||
|
||||
func (t *SnippetFormTab) View() string {
|
||||
contentW := t.width - 12
|
||||
if contentW < 50 {
|
||||
contentW = 50
|
||||
if contentW < 30 {
|
||||
contentW = 30
|
||||
}
|
||||
if contentW > 70 {
|
||||
contentW = 70
|
||||
@@ -243,8 +243,12 @@ func (t *SnippetFormTab) View() string {
|
||||
}
|
||||
|
||||
inner.WriteString("\n")
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
|
||||
SubtitleStyle.Render("Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel")))
|
||||
footerText := "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())
|
||||
var b strings.Builder
|
||||
|
||||
+27
-19
@@ -153,11 +153,10 @@ func (t *SnippetListTab) View() string {
|
||||
text string
|
||||
plain string
|
||||
}
|
||||
|
||||
titlePlain := "Command Snippets"
|
||||
|
||||
var rows []styledRow
|
||||
|
||||
title := "Command Snippets"
|
||||
titlePlain := title
|
||||
|
||||
if t.err != nil {
|
||||
errLine := fmt.Sprintf("Error: %v", t.err)
|
||||
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"
|
||||
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain}
|
||||
|
||||
widestRow := lipgloss.Width(titlePlain)
|
||||
// Responsive width
|
||||
sidePad := adaptiveSidePad(t.width)
|
||||
widestContent := lipgloss.Width(titlePlain)
|
||||
for _, r := range rows {
|
||||
if w := lipgloss.Width(r.plain); w > widestRow {
|
||||
widestRow = w
|
||||
if w := lipgloss.Width(r.plain); w > widestContent {
|
||||
widestContent = w
|
||||
}
|
||||
}
|
||||
if w := lipgloss.Width(footer.plain); w > widestRow {
|
||||
widestRow = w
|
||||
targetW := clampWidth(widestContent+sidePad*2, t.width)
|
||||
innerW := targetW - sidePad*2
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
|
||||
const sidePad = 6
|
||||
targetW := widestRow + sidePad*2
|
||||
// Footer (wrapped)
|
||||
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
|
||||
|
||||
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
|
||||
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
|
||||
inner.WriteString("\n\n")
|
||||
|
||||
// Render rows, excluding the footer (last element)
|
||||
for _, r := range rows[:len(rows)-1] {
|
||||
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
|
||||
for _, r := range rows {
|
||||
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("\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())
|
||||
var b strings.Builder
|
||||
|
||||
+27
-1
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Tab represents a single tab in the TUI
|
||||
@@ -182,15 +183,40 @@ func (tm *TabManager) View() 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 {
|
||||
if len(tm.tabs) == 0 {
|
||||
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
|
||||
for i, tab := range tm.tabs {
|
||||
name := tab.Name()
|
||||
if maxNameW > 0 && lipgloss.Width(name) > maxNameW {
|
||||
name = truncateStr(name, maxNameW)
|
||||
}
|
||||
if i == tm.active {
|
||||
cells = append(cells, TabActiveStyle.Render(name))
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user