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
44 changes: 44 additions & 0 deletions internal/tui/components/empty_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Package components provides reusable, pure render functions for TUI
// components. empty_state.go renders the styled empty-state block shown when a
// screen has no data (tui-personality REQ-TP-007).
package components

import (
"strings"

"github.com/danielxxomg/bak-cli/internal/tui/styles"
)

// RenderEmptyState renders a styled empty-state block composed of an icon, an
// italic message, and a hint describing the next action. It is a stateless
// pure function styled with the package-level EmptyState styles (AGENTS.md
// styles section). All three segments are optional; when all are empty it
// returns the empty string.
//
// Example: RenderEmptyState(icon, "No backups yet", "Run bak backup to create one")
func RenderEmptyState(icon, message, hint string) string {
if icon == "" && message == "" && hint == "" {
return ""
}

var b strings.Builder
switch {
case icon != "" && message != "":
b.WriteString(styles.EmptyStateIconStyle.Render(icon))
b.WriteString(" ")
b.WriteString(styles.EmptyStateMsgStyle.Render(message))
case icon != "":
b.WriteString(styles.EmptyStateIconStyle.Render(icon))
default:
b.WriteString(styles.EmptyStateMsgStyle.Render(message))
}

if hint != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(styles.EmptyStateHintStyle.Render(hint))
}

return b.String()
}
76 changes: 76 additions & 0 deletions internal/tui/components/empty_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package components provides reusable, pure render functions for TUI
// components. empty_state_test.go covers the styled empty-state renderer
// (tui-personality REQ-TP-007) written BEFORE the production code.
package components

import (
"strings"
"testing"
)

// TestRenderEmptyState verifies the styled empty state renders the icon, the
// message, and the hint (REQ-TP-007). Table-driven so each call site's
// icon/message/hint triple is exercised.
func TestRenderEmptyState(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests
tests := []struct {
name string
icon string
message string
hint string
}{
{
name: "no backups",
icon: "∅",
message: "No backups yet",
hint: "Run 'bak backup' to create one",
},
{
name: "no cloud provider",
icon: "☁",
message: "No cloud provider configured",
hint: "Run 'bak cloud login' to connect",
},
{
name: "no restore targets",
icon: "∅",
message: "No backups to restore",
hint: "Run 'bak backup' to create one first",
},
}

for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state
t.Run(tt.name, func(t *testing.T) {
out := RenderEmptyState(tt.icon, tt.message, tt.hint)

if out == "" {
t.Fatal("RenderEmptyState returned empty string")
}
if !strings.Contains(out, tt.icon) {
t.Errorf("empty state missing icon %q: %q", tt.icon, out)
}
if !strings.Contains(out, tt.message) {
t.Errorf("empty state missing message %q: %q", tt.message, out)
}
if !strings.Contains(out, tt.hint) {
t.Errorf("empty state missing hint %q: %q", tt.hint, out)
}
})
}
}

// TestRenderEmptyState_NonEmptySegments triangulates that the renderer
// composes all three segments together (not just one) and is stable across
// repeated calls.
func TestRenderEmptyState_NonEmptySegments(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests
out := RenderEmptyState("∅", "Nothing here", "do something")

// All three pieces appear together in a single render.
if !strings.Contains(out, "∅") || !strings.Contains(out, "Nothing here") || !strings.Contains(out, "do something") {
t.Errorf("empty state did not compose all segments: %q", out)
}

// Idempotent: a second call yields the same output.
if out2 := RenderEmptyState("∅", "Nothing here", "do something"); out2 != out {
t.Errorf("RenderEmptyState not idempotent: %q vs %q", out, out2)
}
}
19 changes: 19 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case screens.ProgressDoneMsg:
return m.handleProgressDoneMsg(msg)

case tea.MouseWheelMsg, tea.MouseClickMsg:
// Mouse navigation is suppressed while the dashboard search input is
// active so wheel/click events can't move the table cursor behind the
// search box (REQ-TP-006). The guard lives on the root Model because
// that is where the search component state lives; DashboardModel has
// no search field. When search is inactive, fall through to the
// sub-model forwarding below so the dashboard handles the event.
if m.screen == ScreenDashboard && m.search.IsActive() {
return m, nil
}
}

// Forward remaining messages to the active sub-model.
Expand Down Expand Up @@ -621,6 +632,14 @@ func (m Model) View() tea.View {
v := tea.NewView(content)
v.AltScreen = true
v.WindowTitle = titleForScreen(m)
// Enable cell-motion mouse capture only on screens that handle mouse
// events (the dashboard). Other screens leave MouseMode at MouseModeNone
// so the terminal retains normal text-selection behavior (REQ-TP-006).
// v2 enables mouse declaratively via this View field — there is no
// tea.WithMouseCellMotion program option.
if m.screen == ScreenDashboard {
v.MouseMode = tea.MouseModeCellMotion
}
return v
}

Expand Down
110 changes: 108 additions & 2 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,8 +1067,8 @@ func TestModel_initDashboard_NilListBackups(t *testing.T) { //nolint:paralleltes

// Dashboard should be usable (empty, no error).
view := d.View().Content
if !strings.Contains(view, "No backups found") {
t.Errorf("nil ListBackups dashboard view = %q, want 'No backups found'", view)
if !strings.Contains(view, "No backups yet") {
t.Errorf("nil ListBackups dashboard view = %q, want 'No backups yet'", view)
}
}

Expand Down Expand Up @@ -3036,3 +3036,109 @@ func TestModel_View_StatusBar(t *testing.T) { //nolint:paralleltest // shared st
t.Errorf("narrow View() should hide status bar, but contains 'bak v1.0.0':\n%s", narrow)
}
}

// =============================================================================
// tui-personality Phase 4 — Mouse navigation (REQ-TP-006)
// RED: declarative v2 mouse mode + search-suppression guard. The mouse mode is
// set declaratively via the tea.View.MouseMode field (v2 API — there is no
// tea.WithMouseCellMotion program option in v2). The search guard lives on the
// root Model because that is where the search component state lives;
// DashboardModel has no search field (documented deviation from design.md).
// =============================================================================

// TestModel_View_MouseMode verifies the dashboard screen (the only screen that
// handles wheel/click) requests MouseModeCellMotion, while screens that don't
// handle mouse leave it at MouseModeNone so the terminal keeps normal
// text-selection behavior (REQ-TP-006).
func TestModel_View_MouseMode(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state
tests := []struct {
name string
setup func() Model
want tea.MouseMode
}{
{"menu disables mouse", func() Model { return modelAtScreen(ScreenMenu) }, tea.MouseModeNone},
{"dashboard enables cell-motion mouse", func() Model { return modelAtScreen(ScreenDashboard) }, tea.MouseModeCellMotion},
{"restore disables mouse (keyboard-only viewport)", func() Model { return modelAtScreen(ScreenRestore) }, tea.MouseModeNone},
}

for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state
t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state
got := tt.setup().View().MouseMode
if got != tt.want {
t.Errorf("%s: View().MouseMode = %v, want %v", tt.name, got, tt.want)
}
})
}
}

// TestModel_MouseSuppressedDuringSearch verifies mouse wheel/click events are
// dropped (dashboard view unchanged) while the dashboard search input is
// active, so scrolling the wheel behind the search box can't move the table
// cursor (REQ-TP-006 guard via search.IsActive()). The assertion is behavioral
// (rendered output identical before/after) per strict-tdd preference for
// user-visible behavior over internal cursor state.
func TestModel_MouseSuppressedDuringSearch(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending
m := NewModel(Deps{
Version: "1.0.0",
ListBackups: func() ([]BackupInfo, error) {
return []BackupInfo{
{ID: "row-1"}, {ID: "row-2"}, {ID: "row-3"},
}, nil
},
})
m.width = 80
m.height = 24
// Navigate to dashboard and activate search.
m2, _ := m.Update(screenChangeMsg{screen: ScreenDashboard})
m = m2.(Model)
m3, _ := m.Update(tea.KeyPressMsg{Code: '/'})
m = m3.(Model)
if !m.search.IsActive() {
t.Fatal("search not active after '/' on dashboard")
}

before := m.dashboard.View().Content

// Wheel down during search: dashboard view must be unchanged.
nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
r := nm.(Model)
if r.dashboard.View().Content != before {
t.Errorf("wheel down during search changed dashboard view (should be suppressed)")
}

// Left click during search: dashboard view must be unchanged.
nm, _ = r.Update(tea.MouseClickMsg{Button: tea.MouseLeft, Y: 2})
r = nm.(Model)
if r.dashboard.View().Content != before {
t.Errorf("click during search changed dashboard view (should be suppressed)")
}
}

// TestModel_MouseActiveWhenSearchInactive verifies the suppression guard is
// search-specific: with search INACTIVE, a mouse wheel event DOES change the
// dashboard view (cursor advances). Triangulates that the guard is not a
// blanket mouse block.
func TestModel_MouseActiveWhenSearchInactive(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending
m := NewModel(Deps{
Version: "1.0.0",
ListBackups: func() ([]BackupInfo, error) {
return []BackupInfo{
{ID: "row-1"}, {ID: "row-2"}, {ID: "row-3"},
}, nil
},
})
m.width = 80
m.height = 24
m2, _ := m.Update(screenChangeMsg{screen: ScreenDashboard})
m = m2.(Model)
if m.search.IsActive() {
t.Fatal("search should be inactive by default")
}

before := m.dashboard.View().Content
nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
r := nm.(Model)
if r.dashboard.View().Content == before {
t.Error("wheel down with search inactive did not change dashboard view (cursor should advance)")
}
}
4 changes: 2 additions & 2 deletions internal/tui/screens/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ func RenderCloudStatus(info CloudInfo, width int) string {
b.WriteString(styles.CloudTitleStyle.Render("Cloud Sync"))
b.WriteString("\n\n")

// No provider configured.
// No provider configured (styled icon + message + hint, REQ-TP-007).
if info.Provider == "" {
b.WriteString(styles.CloudEmptyStyle.Render("No cloud provider configured"))
b.WriteString(components.RenderEmptyState("\u2601", "No cloud provider configured", "Run 'bak cloud login' to connect"))
b.WriteString("\n\n")
b.WriteString(renderCloudHelp())
content := b.String()
Expand Down
16 changes: 16 additions & 0 deletions internal/tui/screens/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ func TestRenderCloudStatus_NoProvider(t *testing.T) { //nolint:paralleltest // n
}
}

// TestRenderCloudStatus_NoProvider_Styled verifies the no-provider branch
// renders the shared styled empty-state block (icon + message + hint) via
// components.RenderEmptyState, not a bare string (tui-personality REQ-TP-007).
func TestRenderCloudStatus_NoProvider_Styled(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending
info := CloudInfo{Provider: ""}

output := RenderCloudStatus(info, 80)

if !strings.Contains(output, "No cloud provider configured") {
t.Errorf("styled no-provider missing message: %q", output)
}
if !strings.Contains(output, "bak cloud login") {
t.Errorf("styled no-provider missing hint 'bak cloud login': %q", output)
}
}

// =============================================================================
// TestRenderCloudStatus_Counts — RED
// =============================================================================
Expand Down
45 changes: 43 additions & 2 deletions internal/tui/screens/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ var dashboardColumns = []table.Column{
{Title: "Cloud", Width: 10},
}

// dashboardMouseHeaderOffset is the number of rendered lines above the table
// data rows (the "Backups" title + a blank line). A mouse click's Y coordinate
// is mapped to a table row index by subtracting this offset (REQ-TP-006).
const dashboardMouseHeaderOffset = 2

// clampCursor clamps cur into the valid table row range [0, rows-1].
func clampCursor(cur, rows int) int {
if cur < 0 {
return 0
}
if cur >= rows {
return rows - 1
}
return cur
}

// DashboardModel is the Bubble Tea sub-model for the dashboard screen.
// It wraps a bubbles/table sub-model and handles keyboard navigation.
//
Expand Down Expand Up @@ -107,6 +123,31 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table = newTable
return m, cmd
}

case tea.MouseWheelMsg:
// Mouse wheel scrolls the backup list (REQ-TP-006).
rows := len(m.table.Rows())
if rows > 0 {
cur := m.table.Cursor()
switch msg.Button {
case tea.MouseWheelDown:
cur++
case tea.MouseWheelUp:
cur--
}
m.table.SetCursor(clampCursor(cur, rows))
}
return m, nil

case tea.MouseClickMsg:
// Left click selects the clicked row (REQ-TP-006).
if msg.Button == tea.MouseLeft {
rows := len(m.table.Rows())
if rows > 0 {
m.table.SetCursor(clampCursor(msg.Y-dashboardMouseHeaderOffset, rows))
}
}
return m, nil
}

// Forward all other messages to the table sub-model.
Expand Down Expand Up @@ -166,9 +207,9 @@ func (m DashboardModel) View() tea.View {
return tea.NewView(b.String())
}

// Empty state.
// Empty state (styled icon + message + hint, REQ-TP-007).
if len(m.table.Rows()) == 0 {
b.WriteString(styles.DashboardEmptyStyle.Render("No backups found"))
b.WriteString(components.RenderEmptyState("∅", "No backups yet", "Run 'bak backup' to create one"))
b.WriteString("\n\n")
b.WriteString(renderDashboardHelp())
return tea.NewView(b.String())
Expand Down
Loading
Loading