package ui import ( "context" "strings" "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" ) type modelState struct { steps []cameradar.Step status map[cameradar.Step]state logs []logMsg summaryStreams []cameradar.Stream summaryFinal bool buildInfo BuildInfo cancel context.CancelFunc debug bool spinner spinner.Model progress progress.Model width int height int quitting bool progressTotals map[cameradar.Step]int progressCounts map[cameradar.Step]int progressTarget float64 progressVisible float64 } func (m *modelState) Init() tea.Cmd { return m.spinner.Tick } func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch typed := msg.(type) { case stepMsg: m.handleStepMsg(typed) case logMsg: m.handleLogMsg(typed) case summaryMsg: m.handleSummaryMsg(typed) case progressMsg: m.handleProgressMsg(typed) case closeMsg: m.quitting = true case tea.KeyMsg: if typed.Type == tea.KeyCtrlC { if m.cancel != nil { m.cancel() } m.quitting = true return m, tea.Quit } case spinner.TickMsg: cmds = m.handleSpinnerMsg(typed) case tea.WindowSizeMsg: m.handleWindowSizeMsg(typed) case progress.FrameMsg: } if len(cmds) == 0 { return m, nil } return m, tea.Batch(cmds...) } func (m *modelState) handleStepMsg(msg stepMsg) { m.status[msg.step] = msg.state if msg.message != "" { level := logInfo if msg.state == stateError { level = logError } m.logs = append(m.logs, logMsg{level: level, step: msg.step, message: msg.message}) } if msg.state == stateDone || msg.state == stateError { markStepComplete(m, msg.step) queueProgressUpdate(m) } } func (m *modelState) handleLogMsg(msg logMsg) { m.logs = append(m.logs, msg) } func (m *modelState) handleSummaryMsg(msg summaryMsg) { m.summaryStreams = msg.streams m.summaryFinal = msg.final if msg.final { m.status[cameradar.StepSummary] = stateDone markStepComplete(m, cameradar.StepSummary) queueProgressUpdate(m) m.quitting = true } } func (m *modelState) handleProgressMsg(msg progressMsg) { if msg.total > 0 { m.progressTotals[msg.step] = msg.total if m.progressCounts[msg.step] > msg.total { m.progressCounts[msg.step] = msg.total } } if msg.increment > 0 { m.progressCounts[msg.step] += msg.increment total := m.progressTotals[msg.step] if total > 0 && m.progressCounts[msg.step] > total { m.progressCounts[msg.step] = total } } queueProgressUpdate(m) } func (m *modelState) handleSpinnerMsg(msg spinner.TickMsg) []tea.Cmd { var cmds []tea.Cmd var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) advanceProgress(m) if m.quitting && progressComplete(*m) { cmds = append(cmds, tea.Quit) } return cmds } func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) { m.width = msg.Width m.height = msg.Height m.progress.Width = progressWidth(msg.Width) } func (m *modelState) View() 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") summaryHeight, logsHeight := m.layoutHeights(len(headerLines), len(stepsLines)) logsLines := m.renderLogs(logsHeight) builder.WriteString(sectionStyle.Render("Logs")) 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") 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") }