Skip to content

Commit 5b7c573

Browse files
committed
fix(tui): stabilize dashboard grid and doctor wrapping
1 parent ecc8ba7 commit 5b7c573

4 files changed

Lines changed: 170 additions & 99 deletions

File tree

cmd/concave-tui/model/dashboard.go

Lines changed: 128 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -199,16 +199,7 @@ func (m DashboardModel) View() string {
199199
return mutedText("No dashboard widgets configured for the active preset")
200200
}
201201

202-
layout := m.layoutWidgets(widgets, m.width, m.height, style)
203-
rendered := make([]string, 0, len(layout))
204-
for _, items := range layout {
205-
columnParts := make([]string, 0, len(items))
206-
for _, item := range items {
207-
columnParts = append(columnParts, item.content)
208-
}
209-
rendered = append(rendered, strings.Join(columnParts, "\n"))
210-
}
211-
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
202+
return padToHeight(m.layoutWidgets(widgets, m.width, m.height, style), m.height)
212203
}
213204

214205
func (m DashboardModel) HelpView() string {
@@ -299,41 +290,36 @@ func (m DashboardModel) widgetByID(id string) (Widget, bool) {
299290

300291
func (m DashboardModel) renderWidgetCard(widget Widget, width, height int, style string) string {
301292
bodyHeight := max(3, height-3)
302-
body := widget.Render(width-4, bodyHeight, style)
293+
body := padToHeight(widget.Render(width-4, bodyHeight, style), bodyHeight)
303294
card := lipgloss.NewStyle().
304295
Width(width).
296+
Height(height).
305297
Border(lipgloss.NormalBorder()).
306298
BorderForeground(lipgloss.Color(ColorDeep)).
307299
Padding(0, 1)
308-
if widgetExpandable(widget.ID()) {
309-
body = padToHeight(body, bodyHeight)
310-
card = card.Height(height)
311-
}
312300
return card.Render(lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true).Render(widget.Title()) + "\n" + body)
313301
}
314302

315-
func (m DashboardModel) layoutWidgets(widgets []Widget, contentWidth, contentHeight int, style string) [][]renderedWidget {
303+
func (m DashboardModel) layoutWidgets(widgets []Widget, contentWidth, contentHeight int, style string) string {
316304
if len(widgets) == 0 {
317-
return nil
305+
return ""
318306
}
319307
columns := min(dashboardColumnsForWidth(contentWidth), len(widgets))
320-
columnWidth := max(22, (contentWidth-(columns-1)*2)/columns)
321-
buckets := make([][]Widget, columns)
322-
for idx, widget := range widgets {
323-
buckets[idx%columns] = append(buckets[idx%columns], widget)
324-
}
325-
326-
result := make([][]renderedWidget, columns)
327-
for idx, bucket := range buckets {
328-
heights := distributeHeight(bucket, contentHeight)
329-
for itemIdx, widget := range bucket {
330-
result[idx] = append(result[idx], renderedWidget{
331-
content: m.renderWidgetCard(widget, columnWidth, heights[itemIdx], style),
332-
height: heights[itemIdx],
333-
})
308+
if columns <= 0 {
309+
return ""
310+
}
311+
rows := chunkWidgets(widgets, columns)
312+
rowHeights := distributeRowHeights(rows, contentHeight)
313+
renderedRows := make([]string, 0, len(rows))
314+
for rowIdx, row := range rows {
315+
cardWidth := rowCardWidth(contentWidth, len(row))
316+
cards := make([]string, 0, len(row))
317+
for _, widget := range row {
318+
cards = append(cards, m.renderWidgetCard(widget, cardWidth, rowHeights[rowIdx], style))
334319
}
320+
renderedRows = append(renderedRows, joinHorizontalCards(cards, rowHeights[rowIdx]))
335321
}
336-
return result
322+
return strings.Join(renderedRows, "\n")
337323
}
338324

339325
func (m DashboardModel) renderGPUWidget(index, width, height int, style string) string {
@@ -624,49 +610,109 @@ func dashboardColumnsForWidth(width int) int {
624610
}
625611
}
626612

627-
func distributeHeight(widgets []Widget, contentHeight int) []int {
628-
if len(widgets) == 0 {
613+
func chunkWidgets(widgets []Widget, columns int) [][]Widget {
614+
if len(widgets) == 0 || columns <= 0 {
629615
return nil
630616
}
617+
rows := make([][]Widget, 0, (len(widgets)+columns-1)/columns)
618+
for start := 0; start < len(widgets); start += columns {
619+
end := min(len(widgets), start+columns)
620+
row := make([]Widget, 0, end-start)
621+
row = append(row, widgets[start:end]...)
622+
rows = append(rows, row)
623+
}
624+
return rows
625+
}
631626

632-
gaps := max(0, len(widgets)-1)
633-
available := max(len(widgets)*4, contentHeight-gaps)
634-
heights := make([]int, len(widgets))
635-
totalMin := 0
636-
expandable := make([]int, 0, len(widgets))
627+
func distributeRowHeights(rows [][]Widget, contentHeight int) []int {
628+
if len(rows) == 0 {
629+
return nil
630+
}
637631

638-
for idx, widget := range widgets {
639-
minHeight := widgetMinHeight(widget.ID())
640-
heights[idx] = minHeight
641-
totalMin += minHeight
642-
if widgetExpandable(widget.ID()) {
643-
expandable = append(expandable, idx)
644-
}
632+
rowGaps := max(0, len(rows)-1)
633+
available := max(len(rows)*4, contentHeight-rowGaps)
634+
preferred := make([]int, len(rows))
635+
weights := make([]int, len(rows))
636+
totalPreferred := 0
637+
totalWeight := 0
638+
for idx, row := range rows {
639+
preferred[idx] = rowPreferredHeight(row)
640+
weights[idx] = rowWeight(row)
641+
totalPreferred += preferred[idx]
642+
totalWeight += weights[idx]
645643
}
646644

647-
if len(expandable) == 0 {
648-
return heights
645+
if totalPreferred > available {
646+
return evenHeights(available, len(rows))
649647
}
650648

651-
remaining := available - totalMin
652-
if remaining <= 0 {
649+
heights := append([]int(nil), preferred...)
650+
remaining := available - totalPreferred
651+
if remaining <= 0 || totalWeight <= 0 {
653652
return heights
654653
}
655-
share := 0
656-
extra := 0
657-
if len(expandable) > 0 {
658-
share = remaining / len(expandable)
659-
extra = remaining % len(expandable)
654+
655+
remainders := make([]int, len(rows))
656+
for idx := range rows {
657+
product := remaining * weights[idx]
658+
heights[idx] += product / totalWeight
659+
remainders[idx] = product % totalWeight
660660
}
661-
for idx, widgetIdx := range expandable {
662-
heights[widgetIdx] += share
663-
if idx < extra {
664-
heights[widgetIdx]++
661+
662+
for assigned := sumInts(heights) - totalPreferred; assigned < remaining; assigned++ {
663+
bestIdx := -1
664+
bestRemainder := -1
665+
for idx, remainder := range remainders {
666+
if remainder > bestRemainder {
667+
bestIdx = idx
668+
bestRemainder = remainder
669+
}
665670
}
671+
if bestIdx < 0 {
672+
break
673+
}
674+
heights[bestIdx]++
675+
remainders[bestIdx] = -1
666676
}
667677
return heights
668678
}
669679

680+
func rowCardWidth(contentWidth, cards int) int {
681+
if cards <= 0 {
682+
return contentWidth
683+
}
684+
gaps := max(0, cards-1)
685+
return max(22, (contentWidth-gaps)/cards)
686+
}
687+
688+
func rowPreferredHeight(row []Widget) int {
689+
height := 4
690+
for _, widget := range row {
691+
height = max(height, widgetMinHeight(widget.ID()))
692+
}
693+
return height
694+
}
695+
696+
func rowWeight(row []Widget) int {
697+
weight := 1
698+
for _, widget := range row {
699+
weight = max(weight, widgetWeight(widget.ID()))
700+
}
701+
return weight
702+
}
703+
704+
func joinHorizontalCards(cards []string, height int) string {
705+
if len(cards) == 0 {
706+
return ""
707+
}
708+
row := cards[0]
709+
gap := lipgloss.NewStyle().Width(1).Height(height).Render("")
710+
for _, card := range cards[1:] {
711+
row = lipgloss.JoinHorizontal(lipgloss.Top, row, gap, card)
712+
}
713+
return row
714+
}
715+
670716
func evenHeights(total, count int) []int {
671717
if count <= 0 {
672718
return nil
@@ -686,15 +732,6 @@ func evenHeights(total, count int) []int {
686732
return heights
687733
}
688734

689-
func widgetExpandable(id string) bool {
690-
switch id {
691-
case "gpu-graph", "gpu-graph-2":
692-
return true
693-
default:
694-
return false
695-
}
696-
}
697-
698735
func widgetMinHeight(id string) int {
699736
switch id {
700737
case "gpu-graph", "gpu-graph-2":
@@ -710,6 +747,29 @@ func widgetMinHeight(id string) int {
710747
}
711748
}
712749

750+
func widgetWeight(id string) int {
751+
switch id {
752+
case "gpu-graph", "gpu-graph-2":
753+
return 4
754+
case "suite-status", "flow-services", "neural-containers":
755+
return 3
756+
case "system-health", "port-map":
757+
return 2
758+
case "vram-bar", "ram-bar":
759+
return 1
760+
default:
761+
return 1
762+
}
763+
}
764+
765+
func sumInts(values []int) int {
766+
total := 0
767+
for _, value := range values {
768+
total += value
769+
}
770+
return total
771+
}
772+
713773
func renderLabeledBlock(label, detail string, totalWidth int) []string {
714774
labelWidth := 10
715775
detailWidth := max(12, totalWidth-labelWidth-1)

cmd/concave-tui/model/dashboard_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -165,23 +165,23 @@ func TestDashboardLayoutAssignsHeightsAndActivationKeepsSnapshot(t *testing.T) {
165165
}
166166
m.appendHistory(m.metrics.GPUs)
167167

168-
layout := m.layoutWidgets(m.widgets(), m.width, m.height, "bar")
169-
if len(layout) == 0 {
170-
t.Fatal("expected widget layout")
171-
}
172-
hasTall := false
173-
for _, column := range layout {
174-
for _, item := range column {
175-
if item.height < 4 {
176-
t.Fatalf("widget height = %d, want at least 4", item.height)
177-
}
178-
if item.height > 10 {
179-
hasTall = true
180-
}
168+
rows := chunkWidgets(m.widgets(), dashboardColumnsForWidth(m.width))
169+
if len(rows) == 0 {
170+
t.Fatal("expected dashboard rows")
171+
}
172+
heights := distributeRowHeights(rows, m.height)
173+
total := 0
174+
for _, height := range heights {
175+
if height < 4 {
176+
t.Fatalf("row height = %d, want at least 4", height)
181177
}
178+
total += height
182179
}
183-
if !hasTall {
184-
t.Fatal("expected at least one expanded dashboard widget")
180+
if total < m.height-2 {
181+
t.Fatalf("row heights = %v, want near full dashboard height %d", heights, m.height)
182+
}
183+
if layout := m.layoutWidgets(m.widgets(), m.width, m.height, "bar"); layout == "" {
184+
t.Fatal("expected rendered dashboard layout")
185185
}
186186
if cmd := m.Activate(); cmd == nil {
187187
t.Fatal("expected tick command on activate")

cmd/concave-tui/model/doctor.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,20 @@ func (m DoctorModel) View() string {
107107
func (m DoctorModel) HelpView() string { return "Doctor\nr re-run checks" }
108108

109109
func renderDoctorBlock(prefix, name, detail, recovery string, totalWidth int) []string {
110-
nameWidth := 18
110+
nameWidth := max(10, min(18, totalWidth/4))
111111
if totalWidth < 60 {
112-
nameWidth = 14
112+
nameWidth = min(nameWidth, 14)
113113
}
114114
if totalWidth < 48 {
115-
nameWidth = 12
115+
nameWidth = min(nameWidth, 12)
116116
}
117117
detailWidth := max(12, totalWidth-nameWidth-4)
118118
wrapped := strings.Split(lipgloss.NewStyle().Width(detailWidth).Render(detail), "\n")
119119
lines := make([]string, 0, len(wrapped)+2)
120+
nameLabel := truncate(name, nameWidth)
120121
for idx, part := range wrapped {
121122
if idx == 0 {
122-
lines = append(lines, fmt.Sprintf("%s %-*s %s", prefix, nameWidth, name, part))
123+
lines = append(lines, fmt.Sprintf("%s %-*s %s", prefix, nameWidth, nameLabel, part))
123124
continue
124125
}
125126
lines = append(lines, fmt.Sprintf("%s %-*s %s", " ", nameWidth, "", part))

cmd/concave-tui/model/settings.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -321,18 +321,7 @@ func (m SettingsModel) View() string {
321321
m.sidebarRadio.View(m.focusedField == settingsFieldSidebar),
322322
"",
323323
lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true).Render("Dashboard Preset"),
324-
}
325-
326-
for idx, name := range m.presetRadio.Options {
327-
marker := mutedText("○")
328-
if idx == m.presetRadio.Selected {
329-
marker = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true).Render("●")
330-
}
331-
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorMuted))
332-
if m.focusedField == settingsFieldPreset && idx == m.presetRadio.Selected {
333-
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true)
334-
}
335-
lines = append(lines, " "+marker+" "+labelStyle.Render(m.presetLabel(name)))
324+
m.renderPresetRow(),
336325
}
337326

338327
lines = append(lines,
@@ -378,3 +367,24 @@ func (m SettingsModel) presetLabel(name string) string {
378367
return strings.TrimSpace(name)
379368
}
380369
}
370+
371+
func (m SettingsModel) renderPresetRow() string {
372+
parts := make([]string, 0, len(m.presetRadio.Options))
373+
for idx, name := range m.presetRadio.Options {
374+
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorMuted))
375+
marker := mutedText("○")
376+
if idx == m.presetRadio.Selected {
377+
marker = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true).Render("●")
378+
}
379+
if m.focusedField == settingsFieldPreset && idx == m.presetRadio.Selected {
380+
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Bold(true)
381+
}
382+
parts = append(parts, marker+" "+labelStyle.Render(m.presetLabel(name)))
383+
}
384+
385+
row := strings.Join(parts, " ")
386+
if m.width <= 0 {
387+
return row
388+
}
389+
return lipgloss.NewStyle().Width(min(m.width-4, 72)).Render(row)
390+
}

0 commit comments

Comments
 (0)