diff --git a/images/example.gif b/images/example.gif index 7c14f71..9864cb2 100644 Binary files a/images/example.gif and b/images/example.gif differ diff --git a/internal/ui/state.go b/internal/ui/state.go index a7a2499..39175ac 100644 --- a/internal/ui/state.go +++ b/internal/ui/state.go @@ -7,6 +7,7 @@ import ( "github.com/Ullaakut/cameradar/v6" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" ) @@ -14,7 +15,6 @@ type modelState struct { steps []cameradar.Step status map[cameradar.Step]state logs []logMsg - summary []summaryTable summaryStreams []cameradar.Stream summaryFinal bool buildInfo BuildInfo @@ -23,6 +23,7 @@ type modelState struct { spinner spinner.Model progress progress.Model width int + height int quitting bool progressTotals map[cameradar.Step]int progressCounts map[cameradar.Step]int @@ -82,7 +83,6 @@ func (m *modelState) handleStepMsg(msg stepMsg) { markStepComplete(m, msg.step) queueProgressUpdate(m) } - m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal) } func (m *modelState) handleLogMsg(msg logMsg) { @@ -92,7 +92,6 @@ func (m *modelState) handleLogMsg(msg logMsg) { func (m *modelState) handleSummaryMsg(msg summaryMsg) { m.summaryStreams = msg.streams m.summaryFinal = msg.final - m.summary = buildSummaryTables(msg.streams, m.width, m.status, msg.final) if msg.final { m.status[cameradar.StepSummary] = stateDone markStepComplete(m, cameradar.StepSummary) @@ -134,55 +133,151 @@ func (m *modelState) handleSpinnerMsg(msg spinner.TickMsg) []tea.Cmd { func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) { m.width = msg.Width + m.height = msg.Height m.progress.Width = progressWidth(msg.Width) - m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal) } func (m *modelState) View() string { var builder strings.Builder - builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader())) - builder.WriteString("\n") - builder.WriteString(renderProgress(m)) - builder.WriteString("\n") + header := sectionStyle.Render(m.buildInfo.TUIHeader()) + headerLines := splitLines(header) + builder.WriteString(strings.Join(headerLines, "\n")) + builder.WriteString("\n\n") - spinnerView := m.spinner.View() - for _, step := range m.steps { - builder.WriteString(renderStep(step, m.status[step], spinnerView)) - builder.WriteString("\n") - } + stepsLines := m.renderSteps() + builder.WriteString(strings.Join(stepsLines, "\n")) + builder.WriteString("\n\n") - builder.WriteString("\n") + summaryHeight, logsHeight := m.layoutHeights(len(headerLines), len(stepsLines)) + logsLines := m.renderLogs(logsHeight) builder.WriteString(sectionStyle.Render("Logs")) builder.WriteString("\n") - if len(m.logs) == 0 { - builder.WriteString(dimStyle.Render("No events yet.")) - builder.WriteString("\n") - } else { - for _, entry := range m.logs { - builder.WriteString(renderLog(entry)) - builder.WriteString("\n") - } - } + builder.WriteString(strings.Join(logsLines, "\n")) + builder.WriteString("\n\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(sectionStyle.Render("Summary")) - builder.WriteString("\n") - for i, summary := range m.summary { - 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 { + for i, summary := range summaryTables { + builder.WriteString(summaryTableStyle.Render(summary.table.View())) + if i < len(summaryTables)-1 { builder.WriteString("\n") } } 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") +} diff --git a/internal/ui/style.go b/internal/ui/style.go index 9c11544..7f633d2 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -4,7 +4,6 @@ import "github.com/charmbracelet/lipgloss" var ( 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")) debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) diff --git a/internal/ui/tui.go b/internal/ui/tui.go index fd14933..27119ed 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -59,17 +59,23 @@ type summaryMsg struct { } type summaryTable struct { - title string - table table.Model - emptyMessage string + table table.Model } +const ( + summaryMinHeight = 8 + summaryMaxHeight = 10 + summaryColumnCount = 8 +) + // TUIReporter renders a Bubble Tea based UI. type TUIReporter struct { program *tea.Program debug bool once sync.Once closed chan struct{} + mu sync.Mutex + last []cameradar.Stream } // 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), 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()) 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 { - _, _ = 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) }() @@ -165,12 +182,28 @@ func (r *TUIReporter) Error(step cameradar.Step, err error) { // Summary implements Reporter. 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. 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. @@ -346,33 +379,153 @@ func progressWidth(width int) int { 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) accessible, others := partitionStreams(streams) rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...) if len(rows) == 0 { - message := "Waiting for results..." - if final { - message = "No streams discovered." - } - return []summaryTable{{title: "Streams", emptyMessage: message}} + rows = []table.Row{emptySummaryRow()} + } + + if maxRows > 0 { + 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) model := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(false), - table.WithHeight(len(rows)+1), + table.WithHeight(len(rows)), ) 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 = "—" +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 { rows := make([]table.Row, 0, len(streams)) for _, stream := range streams {