diff --git a/internal/ui/keymap/keymap.go b/internal/ui/keymap/keymap.go index 8acbe4b..79383e4 100644 --- a/internal/ui/keymap/keymap.go +++ b/internal/ui/keymap/keymap.go @@ -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 } diff --git a/internal/ui/modals/help.go b/internal/ui/modals/help.go index df91d2e..3685817 100644 --- a/internal/ui/modals/help.go +++ b/internal/ui/modals/help.go @@ -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") } } @@ -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 +} diff --git a/internal/ui/modals/help_test.go b/internal/ui/modals/help_test.go index 4adf2d9..2142864 100644 --- a/internal/ui/modals/help_test.go +++ b/internal/ui/modals/help_test.go @@ -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() +}