From 628ba69812a837e8581f60c451536716d1f09f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Sat, 4 Apr 2026 12:50:42 +0200 Subject: [PATCH] Render TUI actions as clickable buttons --- cmd/cloudstic/cmd_tui_input.go | 43 +++++++++-- cmd/cloudstic/cmd_tui_test.go | 14 ++++ internal/tui/shell.go | 133 +++++++++++++++++++++++++++------ internal/tui/shell_test.go | 68 +++++++++++++++-- 4 files changed, 223 insertions(+), 35 deletions(-) diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go index cc215ca..199d3f7 100644 --- a/cmd/cloudstic/cmd_tui_input.go +++ b/cmd/cloudstic/cmd_tui_input.go @@ -29,6 +29,7 @@ const ( type tuiAction struct { Kind tuiActionKind Profile string + Key string } func ensureSelectedProfile(d tui.Dashboard) tui.Dashboard { @@ -181,14 +182,21 @@ func parseTUIMouseAction(csi []byte, layout tui.DashboardLayout) (tuiAction, err if err != nil { return tuiAction{}, nil } - if !pointInRect(x, y, layout.ProfileRect) { - return tuiAction{}, nil + if pointInRect(x, y, layout.ProfileRect) { + profile := layout.ProfileRows[y] + if profile == "" { + return tuiAction{}, nil + } + return tuiAction{Kind: tuiActionSelectProfile, Profile: profile}, nil } - profile := layout.ProfileRows[y] - if profile == "" { - return tuiAction{}, nil + if pointInRect(x, y, layout.ActionRect) { + key := layout.ActionRows[y] + if key == "" { + return tuiAction{}, nil + } + return actionFromKey(key), nil } - return tuiAction{Kind: tuiActionSelectProfile, Profile: profile}, nil + return tuiAction{}, nil } func pointInRect(x, y int, rect tui.Rect) bool { @@ -244,3 +252,26 @@ func profileAction(profile tui.ProfileCard, key string) (tui.ProfileAction, bool } return tui.ProfileAction{}, false } + +func actionFromKey(key string) tuiAction { + switch strings.ToLower(key) { + case "b": + return tuiAction{Kind: tuiActionRun, Key: "b"} + case "c": + return tuiAction{Kind: tuiActionCheck, Key: "c"} + case "e": + return tuiAction{Kind: tuiActionEdit, Key: "e"} + case "d": + return tuiAction{Kind: tuiActionDelete, Key: "d"} + case "n": + return tuiAction{Kind: tuiActionCreate, Key: "n"} + case "q": + return tuiAction{Kind: tuiActionQuit, Key: "q"} + case "j": + return tuiAction{Kind: tuiActionDown, Key: "j"} + case "k": + return tuiAction{Kind: tuiActionUp, Key: "k"} + default: + return tuiAction{} + } +} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 339d5c0..d2f7df7 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -606,6 +606,20 @@ func TestReadTUIAction_ParsesProfileClick(t *testing.T) { } } +func TestReadTUIAction_ParsesActionClick(t *testing.T) { + layout := tui.DashboardLayout{ + ActionRows: map[int]string{12: "c"}, + ActionRect: tui.Rect{X: 30, Y: 10, W: 40, H: 6}, + } + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[<0;35;12M")), layout) + if err != nil { + t.Fatalf("readTUIAction action click: %v", err) + } + if ev.Kind != tuiActionCheck { + t.Fatalf("click action=%v want %v", ev.Kind, tuiActionCheck) + } +} + func TestReadTUIModalInput_ParsesStandaloneEscape(t *testing.T) { ev, err := readTUIModalInput(bufio.NewReader(bytes.NewBufferString("\x1b"))) if err != nil { diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 654ff6b..74cd5be 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -19,6 +19,7 @@ type Rect struct { type DashboardLayout struct { ProfileRows map[int]string ProfileRect Rect + ActionRows map[int]string ActionRect Rect } @@ -50,7 +51,10 @@ func dimmedLine(line string) string { } func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { - layout := DashboardLayout{ProfileRows: map[int]string{}} + layout := DashboardLayout{ + ProfileRows: map[int]string{}, + ActionRows: map[int]string{}, + } y := 1 y += 3 // title, subtitle, blank y += len(boxLinesExact("Overview", []string{ @@ -59,7 +63,7 @@ func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { profilesWidth, detailWidth := splitPaneWidths(width) leftLines := renderProfileList(d) - rightLines := renderSelectedProfile(d) + rightLines, actionRows := renderSelectedProfile(d) leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) leftBox := boxLinesExact("Profiles", leftLines, profilesWidth) leftWidth := longestVisible(leftBox) @@ -75,13 +79,15 @@ func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { for i, profile := range d.Profiles { layout.ProfileRows[contentStartY+i] = profile.Name } - actionRow := len(rightLines) - 1 - if actionRow >= 0 { + if len(actionRows) > 0 { layout.ActionRect = Rect{ X: rightStartX + 2, - Y: contentStartY + actionRow, + Y: contentStartY, W: detailWidth, - H: 1, + H: len(rightLines), + } + for row, key := range actionRows { + layout.ActionRows[contentStartY+row] = key } } return layout @@ -103,7 +109,7 @@ func dashboardLinesWidth(d Dashboard, width int) []string { profilesWidth, detailWidth := splitPaneWidths(width) leftLines := renderProfileList(d) - rightLines := renderSelectedProfile(d) + rightLines, _ := renderSelectedProfile(d) leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) lines = append(lines, renderColumnLines( boxLinesExact("Profiles", leftLines, profilesWidth), @@ -168,9 +174,10 @@ func renderProfileList(d Dashboard) []string { if len(d.Profiles) == 0 { return []string{fmt.Sprintf("%sNo profiles configured.%s", ui.Dim, ui.Reset)} } + nameWidth, badgeWidth := profileListWidths(d.Profiles) lines := make([]string, 0, len(d.Profiles)) for _, profile := range d.Profiles { - lines = append(lines, profileHeaderLine(profile, profile.Name == d.SelectedProfile)) + lines = append(lines, profileHeaderLine(profile, profile.Name == d.SelectedProfile, nameWidth, badgeWidth)) } return lines } @@ -205,10 +212,10 @@ func renderActivityPanel(activity ActivityPanel) []string { return lines } -func renderSelectedProfile(d Dashboard) []string { +func renderSelectedProfile(d Dashboard) ([]string, map[int]string) { profile, ok := selectedProfileCard(d) if !ok { - return []string{fmt.Sprintf("%sNo profile selected.%s", ui.Dim, ui.Reset)} + return []string{fmt.Sprintf("%sNo profile selected.%s", ui.Dim, ui.Reset)}, nil } lines := []string{ fmt.Sprintf("%s%s%s", ui.Bold, profile.Name, ui.Reset), @@ -236,13 +243,21 @@ func renderSelectedProfile(d Dashboard) []string { if profile.StatusNote != "" && (profile.Status != ProfileStatusReady || profile.BackupState != BackupFreshnessNever) { lines = append(lines, profileDetailLine("Status", profile.StatusNote)) } - lines = append(lines, "") - for _, action := range profile.Actions { - lines = append(lines, fmt.Sprintf("%sAction%s %s", ui.Dim, ui.Reset, actionLabel(action))) + buttons := selectedProfileActionButtons(profile) + actionRows := map[int]string{} + if len(buttons) > 0 { + lines = append(lines, "") + for _, button := range buttons { + if button.Enabled { + actionRows[len(lines)] = button.Key + } + lines = append(lines, renderActionButton(button)) + if !button.Enabled && button.Reason != "" { + lines = append(lines, fmt.Sprintf(" %s%s%s", ui.Dim, button.Reason, ui.Reset)) + } + } } - lines = append(lines, fmt.Sprintf("%sAction%s Press e to edit this profile", ui.Dim, ui.Reset)) - lines = append(lines, fmt.Sprintf("%sAction%s Press d to delete this profile", ui.Dim, ui.Reset)) - return lines + return lines, actionRows } func renderModalOverlay(w io.Writer, modal Modal, screenWidth, screenHeight int) error { @@ -387,12 +402,32 @@ func modalLayout(screenWidth int) (startX int, width int) { return startX, width } -func profileHeaderLine(profile ProfileCard, selected bool) string { +func profileHeaderLine(profile ProfileCard, selected bool, nameWidth, badgeWidth int) string { prefix := " " if selected { prefix = fmt.Sprintf("%s› %s", ui.Cyan, ui.Reset) } - return fmt.Sprintf("%s%s%s%s [%s]", prefix, ui.Bold, profile.Name, ui.Reset, profileStateLabel(profile)) + namePadding := nameWidth - visibleLen(profile.Name) + if namePadding < 0 { + namePadding = 0 + } + return fmt.Sprintf("%s%s%s%s%s %s", prefix, ui.Bold, profile.Name, ui.Reset, strings.Repeat(" ", namePadding), profileStateBadge(profile, badgeWidth)) +} + +func profileListWidths(profiles []ProfileCard) (nameWidth, badgeWidth int) { + for _, profile := range profiles { + if l := visibleLen(profile.Name); l > nameWidth { + nameWidth = l + } + labelWidth := visibleLen(plainProfileStateLabel(profile)) + if labelWidth > badgeWidth { + badgeWidth = labelWidth + } + } + if badgeWidth > 0 { + badgeWidth += 2 // brackets + } + return nameWidth, badgeWidth } func profileDetailLine(label, value string) string { @@ -640,6 +675,16 @@ func profileStateLabel(profile ProfileCard) string { } } +func profileStateBadge(profile ProfileCard, width int) string { + label := profileStateLabel(profile) + plainWidth := visibleLen(plainProfileStateLabel(profile)) + padding := width - plainWidth - 2 + if padding < 0 { + padding = 0 + } + return fmt.Sprintf("[%s%s]", label, strings.Repeat(" ", padding)) +} + func plainProfileStateLabel(profile ProfileCard) string { switch profile.Status { case ProfileStatusDisabled: @@ -668,13 +713,53 @@ func selectedProfileCard(d Dashboard) (ProfileCard, bool) { return d.Profiles[0], true } -func actionLabel(action ProfileAction) string { - if action.Enabled { - return action.Label +func trimSnapshotRef(ref string) string { + return strings.TrimPrefix(ref, "snapshot/") +} + +type actionButton struct { + Key string + Label string + Enabled bool + Reason string +} + +func selectedProfileActionButtons(profile ProfileCard) []actionButton { + buttons := make([]actionButton, 0, len(profile.Actions)+2) + for _, action := range profile.Actions { + buttons = append(buttons, actionButton{ + Key: action.Key, + Label: actionButtonLabel(action), + Enabled: action.Enabled, + Reason: action.Reason, + }) + } + buttons = append(buttons, + actionButton{Key: "e", Label: "Edit profile", Enabled: true}, + actionButton{Key: "d", Label: "Delete profile", Enabled: true}, + ) + return buttons +} + +func actionButtonLabel(action ProfileAction) string { + switch action.Kind { + case ActionKindInit: + return "Initialize repository" + case ActionKindCheck: + return "Run check" + default: + if action.Enabled { + return "Run backup" + } + return "Backup unavailable" } - return fmt.Sprintf("%s%s%s", ui.Dim, action.Label, ui.Reset) } -func trimSnapshotRef(ref string) string { - return strings.TrimPrefix(ref, "snapshot/") +func renderActionButton(button actionButton) string { + key := fmt.Sprintf("%s[%s]%s", ui.Cyan, button.Key, ui.Reset) + label := button.Label + if button.Enabled { + return fmt.Sprintf(" %s %s", key, label) + } + return fmt.Sprintf(" %s %s%s%s", key, ui.Dim, label, ui.Reset) } diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index d098e4f..00ce2f9 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -3,6 +3,7 @@ package tui import ( "strings" "testing" + "unicode/utf8" ) func TestRenderDashboard(t *testing.T) { @@ -45,7 +46,7 @@ func TestRenderDashboard(t *testing.T) { if err := RenderDashboard(&out, d); err != nil { t.Fatalf("RenderDashboard: %v", err) } - got := out.String() + got := stripANSI(out.String()) for _, want := range []string{ "Cloudstic TUI", "Operator dashboard for profiles, stores, and auth.", @@ -74,9 +75,10 @@ func TestRenderDashboard(t *testing.T) { "completed successfully", "2026-04-03 15:05:00", "Snapshot abc123 saved", - "Press c to run repository check", - "Press e to edit this profile", - "Press d to delete this profile", + "[b] Run backup", + "[c] Run check", + "[e] Edit profile", + "[d] Delete profile", "Use ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.", } { if !strings.Contains(got, want) { @@ -217,10 +219,66 @@ func TestLayoutDashboardWidth_TracksProfileRowsAndActionRect(t *testing.T) { if layout.ProfileRect.X != 1 || layout.ProfileRect.Y <= 0 { t.Fatalf("unexpected profile rect origin: %+v", layout.ProfileRect) } - if layout.ActionRect.W <= 0 || layout.ActionRect.H != 1 { + if len(layout.ActionRows) != 3 { + t.Fatalf("action rows=%d want 3", len(layout.ActionRows)) + } + if layout.ActionRect.W <= 0 || layout.ActionRect.H <= 0 { t.Fatalf("unexpected action rect: %+v", layout.ActionRect) } if layout.ActionRect.X <= 0 || layout.ActionRect.Y <= 0 { t.Fatalf("unexpected action rect origin: %+v", layout.ActionRect) } } + +func TestRenderProfileList_AlignsStateBadges(t *testing.T) { + d := Dashboard{ + SelectedProfile: "much-longer-name", + Profiles: []ProfileCard{ + {Name: "docs", Enabled: true, Status: ProfileStatusReady}, + {Name: "much-longer-name", Enabled: true, Status: ProfileStatusWarning}, + }, + } + + lines := renderProfileList(d) + if len(lines) != 2 { + t.Fatalf("profile lines=%d want 2", len(lines)) + } + docsIdx := visibleIndex(stripANSI(lines[0]), "[") + longIdx := visibleIndex(stripANSI(lines[1]), "[") + if docsIdx <= 0 || longIdx <= 0 { + t.Fatalf("missing state badge in profile list: %+v", lines) + } + if docsIdx != longIdx { + t.Fatalf("badge columns differ: docs=%d long=%d lines=%+v", docsIdx, longIdx, lines) + } +} + +func stripANSI(s string) string { + var b strings.Builder + inEscape := false + for i := 0; i < len(s); { + switch { + case s[i] == '\x1b': + inEscape = true + i++ + case inEscape: + if s[i] == 'm' { + inEscape = false + } + i++ + default: + r, size := utf8.DecodeRuneInString(s[i:]) + b.WriteRune(r) + i += size + } + } + return b.String() +} + +func visibleIndex(s, needle string) int { + byteIdx := strings.Index(s, needle) + if byteIdx < 0 { + return -1 + } + return utf8.RuneCountInString(s[:byteIdx]) +}