Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/ui/keymap/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ func Default() *Map {
Description: "Interrupt operation",
})

m.Add("sort", Binding{
Keys: []string{"shift+s", "S"},
Help: "sort",
Description: "Open the column sort picker (sortable screens)",
})

m.Add("screen_switch", Binding{
Keys: []string{"1-9", "0"},
Help: "switch screen",
Description: "Quick-switch (1 containers · 2 images · 3 volumes · 4 networks · 5 builder · 6 registry · 7 system · 8 pulses · 9 xray · 0 pinned)",
})

return m
}

Expand Down
98 changes: 70 additions & 28 deletions internal/ui/modals/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,39 +51,65 @@ func (m HelpModel) View(width, height int) string {
content.WriteString(title)
content.WriteString("\n\n")

// Get all bindings
// Build [(label, keys)] entries in stable name-sorted order so help
// stays predictable across runs and does not jitter between screens.
names := m.keymap.Names()
type entry struct{ label, keys string }
entries := make([]entry, 0, len(names))
for _, n := range names {
b, ok := m.keymap.Get(n)
if !ok {
continue
}
entries = append(entries, entry{label: b.Help, keys: formatKeys(b.Keys)})
}

// Determine layout based on width
useTwoColumns := width >= 80

if useTwoColumns {
// Two column layout
midpoint := (len(names) + 1) / 2
leftCol := names[:midpoint]
rightCol := names[midpoint:]

for i := 0; i < midpoint; i++ {
// Left column
name := leftCol[i]
b, _ := m.keymap.Get(name)
left := fmt.Sprintf("%-30s %s", b.Help, formatKeys(b.Keys))

// Right column (if available)
right := ""
if i < len(rightCol) {
name := rightCol[i]
b, _ := m.keymap.Get(name)
right = fmt.Sprintf("%-30s %s", b.Help, formatKeys(b.Keys))
}
// Compute column widths from data so labels and key strings each have
// a stable left edge regardless of which screen is active.
maxLabel, maxKeys := 0, 0
for _, e := range entries {
if l := visibleLen(e.label); l > maxLabel {
maxLabel = l
}
if l := visibleLen(e.keys); l > maxKeys {
maxKeys = l
}
}
const labelKeyGap = 2
const colSep = 4
pairW := maxLabel + labelKeyGap + maxKeys
totalTwoColW := pairW + colSep + pairW

// Two columns when there's room; otherwise one. Account for the
// rounded border + padding (4 cols) added by the box style below.
useTwoCols := width-4 >= totalTwoColW

renderRow := func(e entry) string {
return padRight(e.label, maxLabel) + strings.Repeat(" ", labelKeyGap) + padRight(e.keys, maxKeys)
}

content.WriteString(fmt.Sprintf("%-40s %s\n", left, right))
if useTwoCols {
mid := (len(entries) + 1) / 2
left := entries[:mid]
right := entries[mid:]
gap := strings.Repeat(" ", colSep)
for i := 0; i < mid; i++ {
l := renderRow(left[i])
r := ""
if i < len(right) {
r = renderRow(right[i])
}
content.WriteString(l)
if r != "" {
content.WriteString(gap)
content.WriteString(r)
}
content.WriteString("\n")
}
} else {
// Single column layout
for _, name := range names {
b, _ := m.keymap.Get(name)
content.WriteString(fmt.Sprintf("%-30s %s\n", b.Help, formatKeys(b.Keys)))
for _, e := range entries {
content.WriteString(renderRow(e))
content.WriteString("\n")
}
}

Expand Down Expand Up @@ -113,3 +139,19 @@ func formatKeys(keys []string) string {
}
return "[" + strings.Join(keys, ", ") + "]"
}

// visibleLen returns the visible-rune count of s. It's intentionally
// simple — help labels and keys are ASCII / common punctuation, so a
// rune count matches the on-screen column count.
func visibleLen(s string) int {
return len([]rune(s))
}

// padRight returns s padded with trailing spaces so its visible length
// equals at least n columns.
func padRight(s string, n int) string {
if l := visibleLen(s); l < n {
return s + strings.Repeat(" ", n-l)
}
return s
}
87 changes: 87 additions & 0 deletions internal/ui/modals/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,90 @@ func TestHelpAnyKeyCloses(t *testing.T) {
t.Errorf("expected CloseModalMsg, got %T", msg)
}
}

// TestHelpModel_TwoColumnAlignment is a regression test for the v0.1.3
// help-modal alignment bug: every right-side cell ended at a different
// position because the layout used "%-40s" to pad the combined "label +
// keys" string, but the keys segment varied in length. The fix renders
// each cell as label-padded + gap + keys-padded so columns are stable.
//
// The assertion strips ANSI styling and looks for a stable column where
// the right-side keys all begin (every right cell's "[" should sit at
// the same column after the cross-cell separator).
func TestHelpModel_TwoColumnAlignment(t *testing.T) {
km := keymap.Default()
km.Add("shell", keymap.Binding{Keys: []string{"s"}, Help: "Shell"})
km.Add("logs", keymap.Binding{Keys: []string{"l", "enter"}, Help: "Logs"})
km.Add("inspect", keymap.Binding{Keys: []string{"d"}, Help: "Details"})
km.Add("stop", keymap.Binding{Keys: []string{"x"}, Help: "Stop"})
km.Add("kill", keymap.Binding{Keys: []string{"shift+k", "K"}, Help: "Kill"})
km.Add("restart", keymap.Binding{Keys: []string{"shift+r", "R"}, Help: "Restart"})
km.Add("delete", keymap.Binding{Keys: []string{"shift+d", "D"}, Help: "Delete"})
km.Add("prune", keymap.Binding{Keys: []string{"shift+p", "P"}, Help: "Prune"})

m := NewHelp(km, "Containers", theme.DefaultDark())
out := stripAnsiInTest(m.View(140, 30))

// Find each "[" character on every line and bucket by line. With
// proper alignment, each line in the two-column body has exactly two
// "[" characters and they sit at two stable columns.
firstBracketCol, secondBracketCol := -1, -1
for _, line := range strings.Split(out, "\n") {
// Skip border / title / dismiss-hint rows.
if !strings.Contains(line, "[") {
continue
}
first := strings.Index(line, "[")
second := strings.Index(line[first+1:], "[")
if second < 0 {
continue // single-column row
}
second += first + 1

if firstBracketCol == -1 {
firstBracketCol = first
} else if first != firstBracketCol {
t.Errorf("left-column [ shifted: was at col %d, now at col %d on line %q",
firstBracketCol, first, line)
}
if secondBracketCol == -1 {
secondBracketCol = second
} else if second != secondBracketCol {
t.Errorf("right-column [ shifted: was at col %d, now at col %d on line %q",
secondBracketCol, second, line)
}
}

if firstBracketCol == -1 {
t.Fatal("no two-column body rows found in help output")
}
}

func TestHelpModel_IncludesSortAndScreenSwitch(t *testing.T) {
m := NewHelp(keymap.Default(), "Test", theme.DefaultDark())
out := stripAnsiInTest(m.View(140, 30))
for _, want := range []string{"sort", "shift+s, S", "switch screen", "1-9, 0"} {
if !strings.Contains(out, want) {
t.Errorf("help output missing %q; full text:\n%s", want, out)
}
}
}

func stripAnsiInTest(s string) string {
var b strings.Builder
skip := false
for _, r := range s {
if r == '\x1b' {
skip = true
continue
}
if skip {
if r == 'm' {
skip = false
}
continue
}
b.WriteRune(r)
}
return b.String()
}
Loading