diff --git a/go.mod b/go.mod index cc5746b1..ece6ae89 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/NYTimes/gziphandler v1.1.1 github.com/bep/godartsass/v2 v2.5.0 github.com/cespare/xxhash/v2 v2.3.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/term v0.2.2 github.com/evanw/esbuild v0.28.1 github.com/go-sql-driver/mysql v1.10.0 @@ -50,7 +51,6 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect diff --git a/internal/devtui/model_view.go b/internal/devtui/model_view.go index 1166355b..0559f359 100644 --- a/internal/devtui/model_view.go +++ b/internal/devtui/model_view.go @@ -84,12 +84,12 @@ func (m Model) renderDashboardFooter() string { shortcuts := []tui.Shortcut{ {Key: "↑/↓", Label: "Navigate"}, {Key: "enter", Label: "Select source"}, - {Key: "pgup/pgdn", Label: "Scroll"}, - {Key: "f", Label: "Follow"}, + {Key: "pgup/pgdn", Label: "Scroll logs"}, + {Key: "f", Label: "Follow logs"}, {Key: "tab", Label: "Next tab"}, {Key: "ctrl+c", Label: "Exit"}, } - return tui.ShortcutBar(shortcuts...) + return tui.ShortcutBarFit(m.width, shortcuts...) } if m.activeTab == tabConfig { @@ -99,7 +99,7 @@ func (m Model) renderDashboardFooter() string { {Key: "tab", Label: "Next tab"}, {Key: "ctrl+c", Label: "Exit"}, } - return tui.ShortcutBar(shortcuts...) + return tui.ShortcutBarFit(m.width, shortcuts...) } if m.activeTab == tabOverview { @@ -110,10 +110,10 @@ func (m Model) renderDashboardFooter() string { {Key: "tab", Label: "Next tab"}, {Key: "ctrl+c", Label: "Exit"}, } - return tui.ShortcutBar(shortcuts...) + return tui.ShortcutBarFit(m.width, shortcuts...) } - return tui.ShortcutBar( + return tui.ShortcutBarFit(m.width, tui.Shortcut{Key: "ctrl+p", Label: "Commands"}, tui.Shortcut{Key: "tab", Label: "Next tab"}, tui.Shortcut{Key: "ctrl+c", Label: "Exit"}, diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go index 28637ad3..1a119519 100644 --- a/internal/tui/shortcuts.go +++ b/internal/tui/shortcuts.go @@ -2,6 +2,7 @@ package tui import ( "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" ) // Shortcut describes a single keyboard shortcut to display in a footer bar. @@ -27,11 +28,33 @@ func ShortcutBadge(key, label string) string { // ShortcutBar joins multiple shortcuts into a horizontal bar separated by dividers. func ShortcutBar(shortcuts ...Shortcut) string { + return shortcutBar(" │ ", shortcuts) +} + +// ShortcutBarFit renders a shortcut bar that stays on a single row within +// maxWidth: it falls back to narrower separators when the default bar is too +// wide and truncates with an ellipsis as a last resort. A maxWidth <= 0 means +// unconstrained. +func ShortcutBarFit(maxWidth int, shortcuts ...Shortcut) string { + bar := shortcutBar(" │ ", shortcuts) + if maxWidth <= 0 || lipgloss.Width(bar) <= maxWidth { + return bar + } + + bar = shortcutBar(" │ ", shortcuts) + if lipgloss.Width(bar) <= maxWidth { + return bar + } + + return ansi.Truncate(bar, maxWidth, "…") +} + +func shortcutBar(separator string, shortcuts []Shortcut) string { if len(shortcuts) == 0 { return "" } - sep := lipgloss.NewStyle().Foreground(BorderColor).Render(" │ ") + sep := lipgloss.NewStyle().Foreground(BorderColor).Render(separator) result := ShortcutBadge(shortcuts[0].Key, shortcuts[0].Label) for _, s := range shortcuts[1:] { result += sep + ShortcutBadge(s.Key, s.Label) diff --git a/internal/tui/shortcuts_test.go b/internal/tui/shortcuts_test.go new file mode 100644 index 00000000..0c270cdf --- /dev/null +++ b/internal/tui/shortcuts_test.go @@ -0,0 +1,50 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" +) + +func instanceTabShortcuts() []Shortcut { + return []Shortcut{ + {Key: "↑/↓", Label: "Navigate"}, + {Key: "enter", Label: "Select source"}, + {Key: "pgup/pgdn", Label: "Scroll logs"}, + {Key: "f", Label: "Follow logs"}, + {Key: "tab", Label: "Next tab"}, + {Key: "ctrl+c", Label: "Exit"}, + } +} + +func TestShortcutBarFit_UnconstrainedMatchesShortcutBar(t *testing.T) { + shortcuts := instanceTabShortcuts() + + assert.Equal(t, ShortcutBar(shortcuts...), ShortcutBarFit(0, shortcuts...)) + assert.Equal(t, ShortcutBar(shortcuts...), ShortcutBarFit(500, shortcuts...)) +} + +func TestShortcutBarFit_NarrowSeparatorsOn120Columns(t *testing.T) { + shortcuts := instanceTabShortcuts() + assert.Greater(t, lipgloss.Width(ShortcutBar(shortcuts...)), 120) + + bar := ShortcutBarFit(120, shortcuts...) + + assert.LessOrEqual(t, lipgloss.Width(bar), 120) + assert.Equal(t, 1, lipgloss.Height(bar)) + // All shortcuts survive; only the separators shrink. + assert.Contains(t, bar, "Follow logs") + assert.Contains(t, bar, "Exit") +} + +func TestShortcutBarFit_TruncatesWhenNothingFits(t *testing.T) { + shortcuts := instanceTabShortcuts() + + bar := ShortcutBarFit(40, shortcuts...) + + assert.LessOrEqual(t, lipgloss.Width(bar), 40) + assert.Equal(t, 1, lipgloss.Height(bar)) + assert.True(t, strings.Contains(bar, "…")) +}