feat: tui mode improvements (#395)

This commit is contained in:
Brendan Le Glaunec
2026-02-03 10:19:11 +01:00
committed by GitHub
parent d16443109a
commit c11e3217ea
4 changed files with 302 additions and 55 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+133 -38
View File
@@ -7,6 +7,7 @@ import (
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -14,7 +15,6 @@ type modelState struct {
steps []cameradar.Step steps []cameradar.Step
status map[cameradar.Step]state status map[cameradar.Step]state
logs []logMsg logs []logMsg
summary []summaryTable
summaryStreams []cameradar.Stream summaryStreams []cameradar.Stream
summaryFinal bool summaryFinal bool
buildInfo BuildInfo buildInfo BuildInfo
@@ -23,6 +23,7 @@ type modelState struct {
spinner spinner.Model spinner spinner.Model
progress progress.Model progress progress.Model
width int width int
height int
quitting bool quitting bool
progressTotals map[cameradar.Step]int progressTotals map[cameradar.Step]int
progressCounts map[cameradar.Step]int progressCounts map[cameradar.Step]int
@@ -82,7 +83,6 @@ func (m *modelState) handleStepMsg(msg stepMsg) {
markStepComplete(m, msg.step) markStepComplete(m, msg.step)
queueProgressUpdate(m) queueProgressUpdate(m)
} }
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
} }
func (m *modelState) handleLogMsg(msg logMsg) { func (m *modelState) handleLogMsg(msg logMsg) {
@@ -92,7 +92,6 @@ func (m *modelState) handleLogMsg(msg logMsg) {
func (m *modelState) handleSummaryMsg(msg summaryMsg) { func (m *modelState) handleSummaryMsg(msg summaryMsg) {
m.summaryStreams = msg.streams m.summaryStreams = msg.streams
m.summaryFinal = msg.final m.summaryFinal = msg.final
m.summary = buildSummaryTables(msg.streams, m.width, m.status, msg.final)
if msg.final { if msg.final {
m.status[cameradar.StepSummary] = stateDone m.status[cameradar.StepSummary] = stateDone
markStepComplete(m, cameradar.StepSummary) markStepComplete(m, cameradar.StepSummary)
@@ -134,55 +133,151 @@ func (m *modelState) handleSpinnerMsg(msg spinner.TickMsg) []tea.Cmd {
func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) { func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height
m.progress.Width = progressWidth(msg.Width) m.progress.Width = progressWidth(msg.Width)
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
} }
func (m *modelState) View() string { func (m *modelState) View() string {
var builder strings.Builder var builder strings.Builder
builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader())) header := sectionStyle.Render(m.buildInfo.TUIHeader())
builder.WriteString("\n") headerLines := splitLines(header)
builder.WriteString(renderProgress(m)) builder.WriteString(strings.Join(headerLines, "\n"))
builder.WriteString("\n") builder.WriteString("\n\n")
spinnerView := m.spinner.View() stepsLines := m.renderSteps()
for _, step := range m.steps { builder.WriteString(strings.Join(stepsLines, "\n"))
builder.WriteString(renderStep(step, m.status[step], spinnerView)) builder.WriteString("\n\n")
builder.WriteString("\n")
}
builder.WriteString("\n") summaryHeight, logsHeight := m.layoutHeights(len(headerLines), len(stepsLines))
logsLines := m.renderLogs(logsHeight)
builder.WriteString(sectionStyle.Render("Logs")) builder.WriteString(sectionStyle.Render("Logs"))
builder.WriteString("\n") builder.WriteString("\n")
if len(m.logs) == 0 { builder.WriteString(strings.Join(logsLines, "\n"))
builder.WriteString(dimStyle.Render("No events yet.")) builder.WriteString("\n\n")
builder.WriteString("\n")
} else {
for _, entry := range m.logs {
builder.WriteString(renderLog(entry))
builder.WriteString("\n")
}
}
rowsToShow := max(1, summaryHeight-2)
summaryTitle := renderSummaryTitle(m.summaryStreams)
summaryTables := buildSummaryTables(m.summaryStreams, m.width, m.status, rowsToShow)
builder.WriteString(sectionStyle.Render(summaryTitle))
builder.WriteString("\n") builder.WriteString("\n")
builder.WriteString(sectionStyle.Render("Summary")) for i, summary := range summaryTables {
builder.WriteString("\n") builder.WriteString(summaryTableStyle.Render(summary.table.View()))
for i, summary := range m.summary { if i < len(summaryTables)-1 {
if summary.title != "" {
builder.WriteString(subsectionStyle.Render(summary.title))
builder.WriteString("\n")
}
if summary.emptyMessage != "" {
builder.WriteString(dimStyle.Render(summary.emptyMessage))
builder.WriteString("\n")
} else {
builder.WriteString(summaryTableStyle.Render(summary.table.View()))
builder.WriteString("\n")
}
if i < len(m.summary)-1 {
builder.WriteString("\n") builder.WriteString("\n")
} }
} }
return builder.String() return builder.String()
} }
func (m *modelState) FinalView() string {
var builder strings.Builder
header := sectionStyle.Render(m.buildInfo.TUIHeader())
headerLines := splitLines(header)
builder.WriteString(strings.Join(headerLines, "\n"))
builder.WriteString("\n\n")
stepsLines := m.renderSteps()
builder.WriteString(strings.Join(stepsLines, "\n"))
builder.WriteString("\n\n")
builder.WriteString(sectionStyle.Render("Logs"))
builder.WriteString("\n")
logLines := m.renderLogsAll()
if len(logLines) == 0 {
builder.WriteString(dimStyle.Render("No events yet."))
} else {
builder.WriteString(strings.Join(logLines, "\n"))
}
builder.WriteString("\n\n")
summaryTitle := renderSummaryTitle(m.summaryStreams)
visibility := summaryVisibility(summaryStatusAllDone())
accessible, others := partitionStreams(m.summaryStreams)
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
if len(rows) == 0 {
rows = []table.Row{emptySummaryRow()}
}
columns := summaryColumns(m.width, rows)
builder.WriteString(sectionStyle.Render(summaryTitle))
builder.WriteString("\n")
builder.WriteString(renderSummaryTablePlain(columns, rows))
return builder.String()
}
func (m *modelState) renderSteps() []string {
lines := []string{sectionStyle.Render("Steps"), renderProgress(m)}
spinnerView := m.spinner.View()
for _, step := range m.steps {
lines = append(lines, renderStep(step, m.status[step], spinnerView))
}
return lines
}
func (m *modelState) renderLogs(height int) []string {
if height <= 0 {
return nil
}
if len(m.logs) == 0 {
lines := []string{dimStyle.Render("No events yet.")}
return padLines(lines, height)
}
start := 0
if len(m.logs) > height {
start = len(m.logs) - height
}
lines := make([]string, 0, min(height, len(m.logs)))
for _, entry := range m.logs[start:] {
lines = append(lines, renderLog(entry))
}
return padLines(lines, height)
}
func (m *modelState) renderLogsAll() []string {
if len(m.logs) == 0 {
return nil
}
lines := make([]string, 0, len(m.logs))
for _, entry := range m.logs {
lines = append(lines, renderLog(entry))
}
return lines
}
func (m *modelState) layoutHeights(headerLines, stepsLines int) (summaryHeight, logsHeight int) {
if m.height <= 0 {
return summaryMinHeight, len(m.logs)
}
reserved := headerLines + 1 + stepsLines + 1 + 1 + 1
remaining := m.height - reserved
remaining = max(0, remaining)
switch {
case remaining < summaryMinHeight:
summaryHeight = max(3, remaining)
case remaining > summaryMaxHeight:
summaryHeight = summaryMaxHeight
default:
summaryHeight = remaining
}
logsHeight = max(0, remaining-summaryHeight)
return summaryHeight, logsHeight
}
func padLines(lines []string, height int) []string {
if height <= 0 {
return lines
}
for len(lines) < height {
lines = append(lines, "")
}
return lines
}
func splitLines(value string) []string {
return strings.Split(value, "\n")
}
-1
View File
@@ -4,7 +4,6 @@ import "github.com/charmbracelet/lipgloss"
var ( var (
sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
subsectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111"))
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+169 -16
View File
@@ -59,17 +59,23 @@ type summaryMsg struct {
} }
type summaryTable struct { type summaryTable struct {
title string table table.Model
table table.Model
emptyMessage string
} }
const (
summaryMinHeight = 8
summaryMaxHeight = 10
summaryColumnCount = 8
)
// TUIReporter renders a Bubble Tea based UI. // TUIReporter renders a Bubble Tea based UI.
type TUIReporter struct { type TUIReporter struct {
program *tea.Program program *tea.Program
debug bool debug bool
once sync.Once once sync.Once
closed chan struct{} closed chan struct{}
mu sync.Mutex
last []cameradar.Stream
} }
// NewTUIReporter creates a new Bubble Tea reporter. // NewTUIReporter creates a new Bubble Tea reporter.
@@ -96,7 +102,6 @@ func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel conte
progressTotals: make(map[cameradar.Step]int), progressTotals: make(map[cameradar.Step]int),
progressCounts: make(map[cameradar.Step]int), progressCounts: make(map[cameradar.Step]int),
} }
initial.summary = buildSummaryTables(nil, initial.width, initial.status, false)
p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen()) p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen())
reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})} reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})}
@@ -110,7 +115,19 @@ func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel conte
} }
if rendered, ok := model.(*modelState); ok { if rendered, ok := model.(*modelState); ok {
_, _ = fmt.Fprintln(out, rendered.View()) output := rendered.FinalView()
if len(rendered.summaryStreams) == 0 {
fallback := reporter.snapshotSummary()
if len(fallback) > 0 {
tmp := &modelState{
summaryStreams: fallback,
width: rendered.width,
status: summaryStatusAllDone(),
}
output = tmp.FinalView()
}
}
_, _ = fmt.Fprintln(out, output)
} }
close(reporter.closed) close(reporter.closed)
}() }()
@@ -165,12 +182,28 @@ func (r *TUIReporter) Error(step cameradar.Step, err error) {
// Summary implements Reporter. // Summary implements Reporter.
func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) { func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) {
r.send(summaryMsg{streams: copyStreams(streams), final: true}) cloned := copyStreams(streams)
r.recordSummary(cloned)
r.send(summaryMsg{streams: cloned, final: true})
} }
// UpdateSummary updates the summary section with partial results. // UpdateSummary updates the summary section with partial results.
func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) { func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) {
r.send(summaryMsg{streams: copyStreams(streams), final: false}) cloned := copyStreams(streams)
r.recordSummary(cloned)
r.send(summaryMsg{streams: cloned, final: false})
}
func (r *TUIReporter) recordSummary(streams []cameradar.Stream) {
r.mu.Lock()
defer r.mu.Unlock()
r.last = streams
}
func (r *TUIReporter) snapshotSummary() []cameradar.Stream {
r.mu.Lock()
defer r.mu.Unlock()
return copyStreams(r.last)
} }
// Close implements Reporter. // Close implements Reporter.
@@ -346,33 +379,153 @@ func progressWidth(width int) int {
return 36 return 36
} }
func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, final bool) []summaryTable { func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, maxRows int) []summaryTable {
visibility := summaryVisibility(status) visibility := summaryVisibility(status)
accessible, others := partitionStreams(streams) accessible, others := partitionStreams(streams)
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...) rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
if len(rows) == 0 { if len(rows) == 0 {
message := "Waiting for results..." rows = []table.Row{emptySummaryRow()}
if final { }
message = "No streams discovered."
} if maxRows > 0 {
return []summaryTable{{title: "Streams", emptyMessage: message}} switch {
case len(rows) > maxRows:
if maxRows == 1 {
rows = []table.Row{summaryOverflowRow(len(rows))}
} else {
visibleRows := maxRows - 1
hidden := len(rows) - visibleRows
rows = append(rows[:visibleRows], summaryOverflowRow(hidden))
}
case len(rows) < maxRows:
rows = padSummaryRows(rows, maxRows)
}
} }
title := fmt.Sprintf("Streams (%d accessible / %d total)", len(accessible), len(streams))
columns := summaryColumns(width, rows) columns := summaryColumns(width, rows)
model := table.New( model := table.New(
table.WithColumns(columns), table.WithColumns(columns),
table.WithRows(rows), table.WithRows(rows),
table.WithFocused(false), table.WithFocused(false),
table.WithHeight(len(rows)+1), table.WithHeight(len(rows)),
) )
model.SetStyles(summaryTableStyles()) model.SetStyles(summaryTableStyles())
return []summaryTable{{title: title, table: model}} return []summaryTable{{table: model}}
}
func renderSummaryTitle(streams []cameradar.Stream) string {
accessible, _ := partitionStreams(streams)
return fmt.Sprintf("Summary - Streams (%d accessible / %d total)", len(accessible), len(streams))
}
func summaryStatusAllDone() map[cameradar.Step]state {
status := make(map[cameradar.Step]state)
for _, step := range cameradar.Steps() {
status[step] = stateDone
}
return status
} }
const emptyEntry = "—" const emptyEntry = "—"
func emptySummaryRow() table.Row {
row := make(table.Row, summaryColumnCount)
for i := range row {
row[i] = emptyEntry
}
return row
}
func padSummaryRows(rows []table.Row, maxRows int) []table.Row {
for len(rows) < maxRows {
rows = append(rows, emptySummaryRow())
}
return rows
}
func summaryOverflowRow(hidden int) table.Row {
row := emptySummaryRow()
if hidden <= 0 {
return row
}
label := "\u2026 1 more stream"
if hidden > 1 {
label = fmt.Sprintf("\u2026 %d more streams", hidden)
}
row[0] = label
return row
}
func renderSummaryTablePlain(columns []table.Column, rows []table.Row) string {
colWidths := make([]int, len(columns))
for i, col := range columns {
colWidths[i] = max(col.Width, len([]rune(col.Title)))
}
var builder strings.Builder
builder.WriteString(renderSummaryBorder("┌", "┬", "┐", colWidths))
builder.WriteString("\n")
builder.WriteString(renderSummaryRow(columnTitles(columns), colWidths))
builder.WriteString("\n")
builder.WriteString(renderSummaryBorder("├", "┼", "┤", colWidths))
for _, row := range rows {
builder.WriteString("\n")
builder.WriteString(renderSummaryRow(row, colWidths))
}
builder.WriteString("\n")
builder.WriteString(renderSummaryBorder("└", "┴", "┘", colWidths))
return builder.String()
}
func renderSummaryBorder(left, middle, right string, widths []int) string {
parts := make([]string, 0, len(widths))
for _, width := range widths {
parts = append(parts, strings.Repeat("─", width+2))
}
return left + strings.Join(parts, middle) + right
}
func renderSummaryRow(cells []string, widths []int) string {
var builder strings.Builder
builder.WriteString("│")
for i, width := range widths {
value := ""
if i < len(cells) {
value = cells[i]
}
builder.WriteString(" ")
builder.WriteString(padAndTrim(value, width))
builder.WriteString(" │")
}
return builder.String()
}
func padAndTrim(value string, width int) string {
if width <= 0 {
return ""
}
runes := []rune(value)
if len(runes) > width {
return string(runes[:width])
}
if len(runes) < width {
return string(runes) + strings.Repeat(" ", width-len(runes))
}
return value
}
func columnTitles(columns []table.Column) []string {
if len(columns) == 0 {
return nil
}
titles := make([]string, len(columns))
for i, col := range columns {
titles[i] = col.Title
}
return titles
}
func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row { func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row {
rows := make([]table.Row, 0, len(streams)) rows := make([]table.Row, 0, len(streams))
for _, stream := range streams { for _, stream := range streams {