From d75621e9b442c5dd020bd295f52855c5bfa9ea17 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 19:08:44 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20responsive=20TUI=20layout=20=E2=80=94?= =?UTF-8?q?=20mobile/tablet/desktop=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 7 ++- PROJECT_STATE.md | 19 ++++--- pkg/tui/host_form_tab.go | 12 ++-- pkg/tui/host_list_tab.go | 86 ++++++++++++++-------------- pkg/tui/key_form_tab.go | 12 ++-- pkg/tui/key_list_tab.go | 51 ++++++++++------- pkg/tui/responsive.go | 108 ++++++++++++++++++++++++++++++++++++ pkg/tui/sftp_browser_tab.go | 34 +++++++++--- pkg/tui/snippet_form_tab.go | 12 ++-- pkg/tui/snippet_list_tab.go | 46 ++++++++------- pkg/tui/tabs.go | 28 +++++++++- 11 files changed, 302 insertions(+), 113 deletions(-) create mode 100644 pkg/tui/responsive.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fb99d0c..1e81c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index f1d1852..4715dbf 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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) --- diff --git a/pkg/tui/host_form_tab.go b/pkg/tui/host_form_tab.go index 2f5f7bf..0d8ae10 100644 --- a/pkg/tui/host_form_tab.go +++ b/pkg/tui/host_form_tab.go @@ -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 diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index cf6de13..83d1c23 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -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() diff --git a/pkg/tui/key_form_tab.go b/pkg/tui/key_form_tab.go index 81c8b10..84d2c2a 100644 --- a/pkg/tui/key_form_tab.go +++ b/pkg/tui/key_form_tab.go @@ -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 diff --git a/pkg/tui/key_list_tab.go b/pkg/tui/key_list_tab.go index 603eca4..7be47e9 100644 --- a/pkg/tui/key_list_tab.go +++ b/pkg/tui/key_list_tab.go @@ -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 diff --git a/pkg/tui/responsive.go b/pkg/tui/responsive.go new file mode 100644 index 0000000..5457738 --- /dev/null +++ b/pkg/tui/responsive.go @@ -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) + "…" +} diff --git a/pkg/tui/sftp_browser_tab.go b/pkg/tui/sftp_browser_tab.go index 67a3de7..1d55c4c 100644 --- a/pkg/tui/sftp_browser_tab.go +++ b/pkg/tui/sftp_browser_tab.go @@ -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() } diff --git a/pkg/tui/snippet_form_tab.go b/pkg/tui/snippet_form_tab.go index e093660..16eb033 100644 --- a/pkg/tui/snippet_form_tab.go +++ b/pkg/tui/snippet_form_tab.go @@ -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 diff --git a/pkg/tui/snippet_list_tab.go b/pkg/tui/snippet_list_tab.go index c55e2a9..794046a 100644 --- a/pkg/tui/snippet_list_tab.go +++ b/pkg/tui/snippet_list_tab.go @@ -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 diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go index c64e469..6ea6bb9 100644 --- a/pkg/tui/tabs.go +++ b/pkg/tui/tabs.go @@ -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 {