From abd511ac6368f3da9850de14574e204e5ff804ee Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Sat, 9 May 2026 14:11:54 +0100 Subject: [PATCH] feat: static model tui view --- .golangci.yaml | 2 + go.mod | 20 ++++++- go.sum | 45 +++++++++++++- internal/app/app.go | 14 ++--- internal/tui/model.go | 20 +++++++ internal/tui/model_test.go | 117 +++++++++++++++++++++++++++++++++++++ internal/tui/update.go | 54 +++++++++++++++++ internal/tui/view.go | 32 ++++++++++ 8 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 internal/tui/model.go create mode 100644 internal/tui/model_test.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view.go diff --git a/.golangci.yaml b/.golangci.yaml index 5eb6e69..10002de 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -84,5 +84,7 @@ linters: severity: warning disabled: false exclude: [""] + exhaustive: + default-signifies-exhaustive: true sloglint: kv-only: true diff --git a/go.mod b/go.mod index 72adecb..3af84aa 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,45 @@ module tangled.org/samatkins.co/dockscope go 1.26.3 require ( + github.com/charmbracelet/bubbletea v1.3.10 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 531b7ee..ddad095 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,19 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -12,6 +26,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -23,20 +39,39 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= @@ -51,8 +86,14 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/app/app.go b/internal/app/app.go index 326eefb..209cc61 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,7 +5,10 @@ import ( "fmt" "log/slog" + tea "github.com/charmbracelet/bubbletea" + "tangled.org/samatkins.co/dockscope/internal/docker" + "tangled.org/samatkins.co/dockscope/internal/tui" ) func Run() error { @@ -27,14 +30,9 @@ func Run() error { return fmt.Errorf("list containers: %w", err) } - if len(containers) == 0 { - fmt.Println("No containers found.") - - return nil - } - - for _, c := range containers { - fmt.Printf("%-20s %-12s %-10s %s\n", c.Name, c.ID, c.State, c.Status) + p := tea.NewProgram(tui.NewModel(containers)) + if _, err := p.Run(); err != nil { + return fmt.Errorf("tui: %w", err) } return nil diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..021625e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,20 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "tangled.org/samatkins.co/dockscope/internal/domain" +) + +type Model struct { + containers []domain.Container + cursor int +} + +func NewModel(containers []domain.Container) Model { + return Model{containers: containers} +} + +func (m Model) Init() tea.Cmd { + return tea.EnterAltScreen +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..960c496 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,117 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "tangled.org/samatkins.co/dockscope/internal/domain" +) + +func threeContainers() []domain.Container { + return []domain.Container{ + {Name: "container-1", State: domain.StateRunning}, + {Name: "container-2", State: domain.StateRunning}, + {Name: "container-3", State: domain.StateRunning}, + } +} + +func runeKey(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +func TestNavigation(t *testing.T) { + tests := []struct { + name string + start int + key tea.KeyMsg + expect int + }{ + {"j moves down", 0, runeKey('j'), 1}, + {"j wraps at bottom", 2, runeKey('j'), 0}, + {"k moves up", 2, runeKey('k'), 1}, + {"k wraps at top", 0, runeKey('k'), 2}, + {"down arrow moves down", 0, tea.KeyMsg{Type: tea.KeyDown}, 1}, + {"up arrow moves up", 2, tea.KeyMsg{Type: tea.KeyUp}, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(threeContainers()) + m.cursor = tt.start + + updated, _ := m.Update(tt.key) + got := updated.(Model).cursor + if got != tt.expect { + t.Errorf("cursor = %d, want %d", got, tt.expect) + } + }) + } +} + +func TestQuitKeys(t *testing.T) { + tests := []struct { + name string + key tea.KeyMsg + }{ + {"q quits", runeKey('q')}, + {"ctrl+c quits", tea.KeyMsg{Type: tea.KeyCtrlC}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(threeContainers()) + _, cmd := m.Update(tt.key) + if cmd == nil { + t.Fatal("expected tea.Quit command, got nil") + } + }) + } +} + +func TestViewNoContainers(t *testing.T) { + m := NewModel(nil) + + view := m.View() + if view != emptyMsg { + t.Errorf("expected %q, got %q", emptyMsg, view) + } +} + +func TestViewWithContainersShowsCursorAtSelectedRow(t *testing.T) { + m := NewModel([]domain.Container{ + {Name: "api-gateway", State: domain.StateRunning}, + {Name: "order-svc", State: domain.StatePaused}, + }) + m.cursor = 1 + + view := m.View() + lines := strings.Split(view, "\n") + + if !strings.HasPrefix(lines[0], " ") || strings.HasPrefix(lines[0], ">") { + t.Errorf("row 0 should not have cursor, got %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "> ") { + t.Errorf("row 1 should have cursor, got %q", lines[1]) + } + if !strings.Contains(lines[1], "order-svc") { + t.Errorf("row 1 should contain order-svc, got %q", lines[1]) + } + if !strings.Contains(lines[1], string(domain.StatePaused)) { + t.Errorf("row 1 should contain state, got %q", lines[1]) + } +} + +func TestNavigationWithEmptyListIsNoop(t *testing.T) { + m := NewModel(nil) + + for _, key := range []tea.KeyMsg{runeKey('j'), runeKey('k')} { + updated, _ := m.Update(key) + nm := updated.(Model) + if nm.cursor != 0 { + t.Errorf("cursor = %d after %v on empty list, want 0", nm.cursor, key) + } + m = nm + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..a947dcb --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,54 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +const ( + keyQuit = "q" + keyDown = "j" + keyUp = "k" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + + case tea.KeyDown: + return m.moveCursor(+1), nil + + case tea.KeyUp: + return m.moveCursor(-1), nil + + case tea.KeyRunes: + switch keyMsg.String() { + case keyQuit: + return m, tea.Quit + case keyDown: + return m.moveCursor(+1), nil + case keyUp: + return m.moveCursor(-1), nil + } + default: + } + + return m, nil +} + +func (m Model) moveCursor(delta int) Model { + n := len(m.containers) + if n == 0 { + m.cursor = 0 + return m + } + + m.cursor = ((m.cursor+delta)%n + n) % n + + return m +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..45cc5fe --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,32 @@ +package tui + +import ( + "fmt" + "strings" +) + +const ( + emptyMsg = "No containers running\n\nPress " + keyQuit + " to quit" + footerHelp = "\nPress " + keyQuit + " to quit, " + keyDown + "/" + keyUp + " to navigate" +) + +func (m Model) View() string { + if len(m.containers) == 0 { + return emptyMsg + } + + var b strings.Builder + + for i, c := range m.containers { + cursor := " " + if i == m.cursor { + cursor = ">" + } + + fmt.Fprintf(&b, "%s %-24s %s\n", cursor, c.Name, c.State) + } + + b.WriteString(footerHelp) + + return b.String() +} -- 2.51.2