Skip to content

Commit c4b9c7b

Browse files
committed
Metrics bar fills the screen with vibrant palette
With this change metrics bar will now fill the whole screen, using the following strategy: * Try to equalize the widths of all metrics, adding spaces as needed from left to right, to ensure visual balance. * When there is not enough room, extra white spaces are trimmed from the widest metrics first, again from left to right. * If there are no more insignificant spaces to trim, metric values will start to get truncated from the right side to ensure the height is always 1 line. I will try to gradually introduce colors, as the TUI looks bleak at the moment. The metrics bar now uses a blue background.
1 parent f9a45c1 commit c4b9c7b

5 files changed

Lines changed: 244 additions & 23 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
Bubble Tea TUI for Sidekiq monitoring. Go 1.25.
66

7+
## Code Quality
8+
9+
* Make sure to always perform `mise run ci` to ensure code formatting matches the style of the repository, linters don't have any offenses, and tests pass.
10+
711
## Structure
812

913
```text

internal/ui/app.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ func New(client *sidekiq.Client) App {
8686
metrics: metrics.New(
8787
metrics.WithStyles(metrics.Styles{
8888
Bar: styles.MetricsBar,
89-
Label: styles.MetricLabel,
90-
Value: styles.MetricValue,
91-
Separator: styles.MetricSep,
89+
Fill: styles.MetricsFill,
90+
Label: styles.MetricsLabel,
91+
Value: styles.MetricsValue,
92+
Separator: styles.MetricsSep,
9293
}),
9394
),
9495
navbar: navbar.New(

internal/ui/components/metrics/metrics.go

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package metrics
22

33
import (
4+
"strings"
5+
46
tea "github.com/charmbracelet/bubbletea"
57
"github.com/charmbracelet/lipgloss"
8+
"github.com/charmbracelet/x/ansi"
69
"github.com/kpumuk/lazykiq/internal/ui/format"
710
)
811

@@ -25,6 +28,7 @@ type UpdateMsg struct {
2528
// Styles holds the styles needed by the metrics bar.
2629
type Styles struct {
2730
Bar lipgloss.Style
31+
Fill lipgloss.Style
2832
Label lipgloss.Style
2933
Value lipgloss.Style
3034
Separator lipgloss.Style
@@ -34,6 +38,7 @@ type Styles struct {
3438
func DefaultStyles() Styles {
3539
return Styles{
3640
Bar: lipgloss.NewStyle().Padding(0, 1),
41+
Fill: lipgloss.NewStyle(),
3742
Label: lipgloss.NewStyle().Faint(true),
3843
Value: lipgloss.NewStyle().Bold(true),
3944
Separator: lipgloss.NewStyle().Faint(true),
@@ -132,9 +137,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
132137
func (m Model) View() string {
133138
barStyle := m.styles.Bar.Width(m.width)
134139

135-
sep := m.styles.Separator.Render(" │ ")
136-
137-
metricsItems := []string{
140+
baseMetrics := []string{
138141
m.styles.Label.Render("Processed: ") + m.styles.Value.Render(format.Number(m.data.Processed)),
139142
m.styles.Label.Render("Failed: ") + m.styles.Value.Render(format.Number(m.data.Failed)),
140143
m.styles.Label.Render("Busy: ") + m.styles.Value.Render(format.Number(m.data.Busy)),
@@ -144,13 +147,113 @@ func (m Model) View() string {
144147
m.styles.Label.Render("Dead: ") + m.styles.Value.Render(format.Number(m.data.Dead)),
145148
}
146149

147-
content := ""
148-
for i, metric := range metricsItems {
149-
if i > 0 {
150-
content += sep
150+
if m.width <= 0 || len(baseMetrics) == 0 {
151+
return barStyle.Render("")
152+
}
153+
154+
contentWidth := m.width - barStyle.GetHorizontalPadding()
155+
if contentWidth < 0 {
156+
contentWidth = 0
157+
}
158+
159+
baseItems, baseWidths, maxWidth := buildBaseItems(baseMetrics, m.styles)
160+
sep := m.styles.Separator.Render(" ")
161+
sepWidth := lipgloss.Width(sep)
162+
163+
targetWidths := layoutTargetWidths(baseWidths, maxWidth, contentWidth, sepWidth)
164+
if targetWidths == nil {
165+
content := strings.Join(baseItems, sep)
166+
content = ansi.Truncate(content, contentWidth, "")
167+
return barStyle.Render(content)
168+
}
169+
170+
metricsItems := applyWidths(baseItems, targetWidths, m.styles.Fill)
171+
return barStyle.Render(strings.Join(metricsItems, sep))
172+
}
173+
174+
func buildBaseItems(metrics []string, styles Styles) ([]string, []int, int) {
175+
pad := styles.Fill.Render(" ")
176+
items := make([]string, len(metrics))
177+
widths := make([]int, len(metrics))
178+
maxWidth := 0
179+
180+
for i, metric := range metrics {
181+
item := pad + metric + pad
182+
width := lipgloss.Width(item)
183+
items[i] = item
184+
widths[i] = width
185+
if width > maxWidth {
186+
maxWidth = width
151187
}
152-
content += metric
153188
}
154189

155-
return barStyle.Render(content)
190+
return items, widths, maxWidth
191+
}
192+
193+
func layoutTargetWidths(baseWidths []int, maxWidth, contentWidth, sepWidth int) []int {
194+
if len(baseWidths) == 0 {
195+
return []int{}
196+
}
197+
198+
minTotal := 0
199+
for _, width := range baseWidths {
200+
minTotal += width
201+
}
202+
minTotal += sepWidth * (len(baseWidths) - 1)
203+
204+
if contentWidth < minTotal {
205+
return nil
206+
}
207+
208+
targetWidths := make([]int, len(baseWidths))
209+
for i := range targetWidths {
210+
targetWidths[i] = maxWidth
211+
}
212+
213+
totalEqual := maxWidth*len(baseWidths) + sepWidth*(len(baseWidths)-1)
214+
if contentWidth >= totalEqual {
215+
extra := contentWidth - totalEqual
216+
for extra > 0 {
217+
for i := range targetWidths {
218+
targetWidths[i]++
219+
extra--
220+
if extra == 0 {
221+
break
222+
}
223+
}
224+
}
225+
return targetWidths
226+
}
227+
228+
overflow := totalEqual - contentWidth
229+
for overflow > 0 {
230+
trimmed := false
231+
for i := range targetWidths {
232+
if targetWidths[i] > baseWidths[i] {
233+
targetWidths[i]--
234+
overflow--
235+
trimmed = true
236+
if overflow == 0 {
237+
break
238+
}
239+
}
240+
}
241+
if !trimmed {
242+
return nil
243+
}
244+
}
245+
246+
return targetWidths
247+
}
248+
249+
func applyWidths(items []string, targetWidths []int, fillStyle lipgloss.Style) []string {
250+
applied := make([]string, len(items))
251+
for i, item := range items {
252+
width := lipgloss.Width(item)
253+
if targetWidths[i] > width {
254+
item += fillStyle.Render(strings.Repeat(" ", targetWidths[i]-width))
255+
}
256+
applied[i] = item
257+
}
258+
return applied
156259
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package metrics
2+
3+
import (
4+
"testing"
5+
6+
"github.com/charmbracelet/lipgloss"
7+
)
8+
9+
func testStyles() Styles {
10+
return Styles{
11+
Bar: lipgloss.NewStyle(),
12+
Fill: lipgloss.NewStyle(),
13+
Label: lipgloss.NewStyle(),
14+
Value: lipgloss.NewStyle(),
15+
Separator: lipgloss.NewStyle(),
16+
}
17+
}
18+
19+
func testData() Data {
20+
return Data{
21+
Processed: 1,
22+
Failed: 22,
23+
Busy: 333,
24+
Enqueued: 4444,
25+
Retries: 55555,
26+
Scheduled: 6,
27+
Dead: 7777777,
28+
}
29+
}
30+
31+
func TestViewSnapshots(t *testing.T) {
32+
data := testData()
33+
cases := []struct {
34+
name string
35+
width int
36+
expected string
37+
}{
38+
{
39+
name: "truncate",
40+
width: 99,
41+
expected: " Processed: 1 Failed: 22 Busy: 333 Enqueued: 4.4K Retries: 55.6K Scheduled: 6 Dead: 7.8",
42+
},
43+
{
44+
name: "min-spacing",
45+
width: 101,
46+
expected: " Processed: 1 Failed: 22 Busy: 333 Enqueued: 4.4K Retries: 55.6K Scheduled: 6 Dead: 7.8M ",
47+
},
48+
{
49+
name: "trim-padding",
50+
width: 116,
51+
expected: " Processed: 1 Failed: 22 Busy: 333 Enqueued: 4.4K Retries: 55.6K Scheduled: 6 Dead: 7.8M ",
52+
},
53+
{
54+
name: "equal-width",
55+
width: 118,
56+
expected: " Processed: 1 Failed: 22 Busy: 333 Enqueued: 4.4K Retries: 55.6K Scheduled: 6 Dead: 7.8M ",
57+
},
58+
{
59+
name: "extra-distributed",
60+
width: 120,
61+
expected: " Processed: 1 Failed: 22 Busy: 333 Enqueued: 4.4K Retries: 55.6K Scheduled: 6 Dead: 7.8M ",
62+
},
63+
}
64+
65+
for _, tc := range cases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
m := New(
68+
WithStyles(testStyles()),
69+
WithWidth(tc.width),
70+
WithData(data),
71+
)
72+
if got := m.View(); got != tc.expected {
73+
t.Fatalf("unexpected output:\nexpected %q\ngot %q", tc.expected, got)
74+
}
75+
})
76+
}
77+
}

internal/ui/theme/theme.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ type Theme struct {
1414
TextBright lipgloss.AdaptiveColor
1515

1616
// Background colors
17-
Bg lipgloss.AdaptiveColor
18-
BgAlt lipgloss.AdaptiveColor
17+
Bg lipgloss.AdaptiveColor
18+
BgAlt lipgloss.AdaptiveColor
19+
MetricsBarBg lipgloss.AdaptiveColor
1920

2021
// Border colors
2122
Border lipgloss.AdaptiveColor
@@ -26,9 +27,14 @@ type Theme struct {
2627
TableSelectedBg lipgloss.AdaptiveColor
2728
Success lipgloss.AdaptiveColor
2829
Error lipgloss.AdaptiveColor
30+
31+
// Metrics colors
32+
MetricsText lipgloss.AdaptiveColor
33+
MetricsSepBg lipgloss.AdaptiveColor
2934
}
3035

3136
// DefaultTheme is the adaptive color scheme used by default.
37+
// Use Open Color palette when possible to define colors: https://yeun.github.io/open-color/
3238
var DefaultTheme = Theme{
3339
// Sidekiq-inspired primary
3440
Primary: lipgloss.AdaptiveColor{
@@ -63,6 +69,10 @@ var DefaultTheme = Theme{
6369
Light: "#F3F4F6", // Gray-100
6470
Dark: "#1F2937", // Gray-800
6571
},
72+
MetricsBarBg: lipgloss.AdaptiveColor{
73+
Light: "#1c7ed6", // blue 7
74+
Dark: "#4dabf7", // blue 4
75+
},
6676

6777
// Borders
6878
Border: lipgloss.AdaptiveColor{
@@ -91,15 +101,28 @@ var DefaultTheme = Theme{
91101
Light: "#FF0000",
92102
Dark: "#FF0000",
93103
},
104+
105+
// Metrics
106+
MetricsText: lipgloss.AdaptiveColor{
107+
Light: "#f8f9fa",
108+
Dark: "#212529", //gray 9
109+
},
110+
MetricsSepBg: lipgloss.AdaptiveColor{
111+
Light: "#1971c2", // blue 8
112+
Dark: "#339af0", // blue 5
113+
},
94114
}
95115

96116
// Styles holds all lipgloss styles derived from a theme
97117
type Styles struct {
98118
// Metrics bar
99-
MetricsBar lipgloss.Style
100-
MetricLabel lipgloss.Style
101-
MetricValue lipgloss.Style
102-
MetricSep lipgloss.Style
119+
MetricsBar lipgloss.Style
120+
MetricsFill lipgloss.Style
121+
MetricsLabel lipgloss.Style
122+
MetricsValue lipgloss.Style
123+
MetricsSep lipgloss.Style
124+
MetricLabel lipgloss.Style
125+
MetricValue lipgloss.Style
103126

104127
// Navbar
105128
NavBar lipgloss.Style
@@ -137,8 +160,24 @@ func NewStyles() Styles {
137160
return Styles{
138161
// Metrics bar
139162
MetricsBar: lipgloss.NewStyle().
140-
Foreground(t.Text).
141-
Padding(0, 1),
163+
Foreground(t.MetricsText).
164+
Background(t.MetricsBarBg).
165+
Padding(0, 0),
166+
167+
MetricsFill: lipgloss.NewStyle().
168+
Background(t.MetricsBarBg),
169+
170+
MetricsLabel: lipgloss.NewStyle().
171+
Foreground(t.MetricsText).
172+
Background(t.MetricsBarBg),
173+
174+
MetricsValue: lipgloss.NewStyle().
175+
Foreground(t.MetricsText).
176+
Background(t.MetricsBarBg).
177+
Bold(true),
178+
179+
MetricsSep: lipgloss.NewStyle().
180+
Background(t.MetricsSepBg),
142181

143182
MetricLabel: lipgloss.NewStyle().
144183
Foreground(t.TextMuted),
@@ -147,9 +186,6 @@ func NewStyles() Styles {
147186
Foreground(t.Text).
148187
Bold(true),
149188

150-
MetricSep: lipgloss.NewStyle().
151-
Foreground(t.Border),
152-
153189
// Navbar
154190
NavBar: lipgloss.NewStyle().
155191
Padding(0, 1),

0 commit comments

Comments
 (0)