From 2bae1a35795a2d925bbfa7dc51a15a3b551bb37a Mon Sep 17 00:00:00 2001 From: Gustavo Castro Date: Sun, 19 Apr 2026 19:39:09 +0200 Subject: [PATCH] fix: standardize ui Small fix to make spacing the same across separate views --- internal/ui/rollinglog.go | 67 ++++++++++++++++++++++++++++++++-- internal/ui/rollinglog_test.go | 4 +- internal/ui/spinner.go | 40 ++++++++++++-------- internal/ui/spinner_test.go | 6 --- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/internal/ui/rollinglog.go b/internal/ui/rollinglog.go index 2a739c5..d935e49 100644 --- a/internal/ui/rollinglog.go +++ b/internal/ui/rollinglog.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -15,6 +16,26 @@ import ( "github.com/gcstr/dockform/internal/logger" ) +// activeProgram tracks the Bubble Tea program currently owning stdout while +// RunWithRollingLog is active. Spinner.SetLabel reads this to forward label +// updates into the rolling log view instead of animating on stdout directly. +var ( + activeMu sync.RWMutex + activeProgram *tea.Program +) + +func getActiveProgram() *tea.Program { + activeMu.RLock() + defer activeMu.RUnlock() + return activeProgram +} + +func setActiveProgram(p *tea.Program) { + activeMu.Lock() + activeProgram = p + activeMu.Unlock() +} + // maxLogLines is the number of rolling log lines displayed in the UI. const maxLogLines = 5 @@ -53,6 +74,11 @@ func RunWithRollingLog(ctx context.Context, fn func(ctx context.Context) (string m := model{state: stateRunning, width: initialWidth, cancelCh: cancelCh} p := tea.NewProgram(m, tea.WithOutput(os.Stdout)) + // Expose the program so Spinner.SetLabel can forward status updates to + // the rolling UI instead of fighting for stdout. + setActiveProgram(p) + defer setActiveProgram(nil) + // Wire up display logger: intercepts structured log events and formats // lines for the UI via appendLog messages. displayLog := newDisplayLogger(func(line string) { p.Send(appendLog{line: line}) }) @@ -138,6 +164,8 @@ var displayNoiseKeys = map[string]bool{ "command": true, } +var statusSpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + var ( displayStyleKey = lipgloss.NewStyle().Faint(true) displayLevelStyles = map[string]lipgloss.Style{ @@ -236,6 +264,18 @@ const ( type appendLog struct{ line string } type done struct{ report string } type interrupted struct{} +type statusUpdate struct{ label string } +type statusTick struct{} + +// statusSpinnerFrames mirrors ui.Spinner so the TUI and the standalone +// spinner use the same animation. +var statusSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +const statusTickDelay = 100 * time.Millisecond + +func statusTickCmd() tea.Cmd { + return tea.Tick(statusTickDelay, func(time.Time) tea.Msg { return statusTick{} }) +} type model struct { state state @@ -243,9 +283,11 @@ type model struct { logLines []string // newest last, max maxLogLines finalReport string cancelCh chan struct{} // Signal channel for Ctrl+C + statusLabel string // current progress label (e.g., "Applying -> creating volume foo") + statusFrame int // spinner animation frame } -func (m model) Init() tea.Cmd { return nil } +func (m model) Init() tea.Cmd { return statusTickCmd() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -270,6 +312,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.logLines) > maxLogLines { m.logLines = m.logLines[len(m.logLines)-maxLogLines:] } + case statusUpdate: + m.statusLabel = msg.label + case statusTick: + if m.state == stateRunning { + m.statusFrame++ + return m, statusTickCmd() + } case done: m.finalReport = msg.report m.state = stateFinal @@ -292,9 +341,19 @@ func (m model) View() string { var b strings.Builder switch m.state { case stateRunning: - // Leading blank line separates the rolling block from the - // Identifier/Contexts header printed immediately before us. - b.WriteByte('\n') + // Status line: animated frame + current label (e.g., "Applying -> creating volume foo"). + // DisplayDaemonInfo prints a trailing blank line, so we don't add our own leading spacer. + // Sits above the rolling log so the current phase is always visible. + if m.statusLabel != "" { + frame := statusSpinnerFrames[m.statusFrame%len(statusSpinnerFrames)] + statusLine := borderPrefix + statusSpinnerStyle.Render(frame) + " " + m.statusLabel + if m.width > 1 { + statusLine = ansi.Truncate(statusLine, m.width-1, "") + } + b.WriteString(statusLine) + b.WriteByte('\n') + b.WriteByte('\n') + } for _, l := range m.logLines { // Build the complete line (border + content) first, then truncate // the WHOLE thing to m.width-1. This guarantees the line never diff --git a/internal/ui/rollinglog_test.go b/internal/ui/rollinglog_test.go index 273a1da..1e55c58 100644 --- a/internal/ui/rollinglog_test.go +++ b/internal/ui/rollinglog_test.go @@ -234,8 +234,8 @@ func TestTruncOneRowANSI(t *testing.T) { } func TestModelInitAndRunningView(t *testing.T) { - if cmd := (model{}).Init(); cmd != nil { - t.Fatalf("expected nil init cmd") + if cmd := (model{}).Init(); cmd == nil { + t.Fatalf("expected non-nil init cmd (status tick)") } m := model{state: stateRunning, width: 80, logLines: []string{"line"}} view := m.View() diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index b1acc48..08218dc 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -22,9 +22,6 @@ type Spinner struct { delay time.Duration enabled bool - // spacer ensures a blank line above the spinner while running - spacerAdded bool - stopCh chan struct{} doneCh chan struct{} mu sync.Mutex @@ -70,13 +67,20 @@ func NewSpinner(out io.Writer, label string) *Spinner { func (s *Spinner) Start() { s.mu.Lock() defer s.mu.Unlock() - if !s.enabled || s.stopCh == nil || s.doneCh == nil { + // When the rolling TUI owns stdout, forward the initial label to the + // Bubble Tea program so it can render a status line inside the rolling + // block. The stdout animation stays disabled. + if !s.enabled { + if p := getActiveProgram(); p != nil { + s.labelMu.RLock() + label := s.label + s.labelMu.RUnlock() + p.Send(statusUpdate{label: label}) + } return } - // Insert a visual spacer line before spinner so it doesn't hug previous output - if !s.spacerAdded { - _, _ = fmt.Fprint(s.out, "\n") - s.spacerAdded = true + if s.stopCh == nil || s.doneCh == nil { + return } // If already running, do nothing select { @@ -120,13 +124,25 @@ func (s *Spinner) SetLabel(label string) { s.labelMu.Lock() s.label = label s.labelMu.Unlock() + // Forward label updates to the rolling TUI when it's the active owner + // of stdout, so the status line above the rolling log stays current. + if p := getActiveProgram(); p != nil { + p.Send(statusUpdate{label: label}) + } } // Stop stops the spinner and clears the line. func (s *Spinner) Stop() { s.mu.Lock() defer s.mu.Unlock() - if !s.enabled || s.stopCh == nil || s.doneCh == nil { + // Clear the status line in the rolling TUI if it's active. + if !s.enabled { + if p := getActiveProgram(); p != nil { + p.Send(statusUpdate{label: ""}) + } + return + } + if s.stopCh == nil || s.doneCh == nil { return } // Signal stop @@ -137,10 +153,4 @@ func (s *Spinner) Stop() { close(s.stopCh) <-s.doneCh } - // After the spinner line is cleared by the goroutine, also remove the spacer line - if s.spacerAdded { - // Move cursor up one line and clear it - _, _ = fmt.Fprint(s.out, "\x1b[1A\x1b[2K") - s.spacerAdded = false - } } diff --git a/internal/ui/spinner_test.go b/internal/ui/spinner_test.go index 8a0aeed..9d14639 100644 --- a/internal/ui/spinner_test.go +++ b/internal/ui/spinner_test.go @@ -22,15 +22,9 @@ func TestSpinnerStartStopEnabled(t *testing.T) { sp.Stop() out := buf.String() - if !strings.Contains(out, "\n") { - t.Fatalf("expected spinner to add spacer newline, got %q", out) - } if !strings.Contains(out, "working") { t.Fatalf("expected spinner label in output, got %q", out) } - if sp.spacerAdded { - t.Fatalf("expected spacer flag to reset on Stop") - } } func TestSpinnerHiddenViaEnv(t *testing.T) {