feat: tui mode improvements (#395)
This commit is contained in:
committed by
GitHub
parent
d16443109a
commit
c11e3217ea
+133
-38
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+169
-16
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user