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`
|
- **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 (60–100), wide (≥100)
|
- Breakpoints: compact (<60), medium (60–100), wide (≥100)
|
||||||
|
|
||||||
## v1.0.0 (2025-01-30)
|
## v1.0.0 (2025-01-30)
|
||||||
|
|||||||
+10
-9
@@ -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 (60–100 / 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 (60–100 / tablet), wide (≥100 / desktop)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user