Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 14 additions & 34 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,45 +392,25 @@ func (m Model) View() string {
if m.showProcesses {
return renderProcesses(m)
}
switch m.effectiveViewMode() {
case ViewTiny:
mode, lines := pickViewModeAndContent(m)
if mode == ViewTiny {
return renderTiny(m)
case ViewMini:
return renderMini(m)
case ViewCompact:
return renderCompact(m)
default:
return renderHero(m)
}
termW := m.width
termH := m.height
if termH <= 0 {
termH = 24
}
return centerFrame(strings.Join(lines, "\n"), termW, termH)
}

// effectiveViewMode reports which view mode pickViewModeAndContent would
// pick for the current terminal size, without needing its rendered
// content. Exists mainly for tests and callers that only care about the
// decision.
func (m Model) effectiveViewMode() ViewMode {
if m.viewMode == ViewTiny {
return ViewTiny
}
if m.viewMode == ViewMini {
return ViewMini
}
if m.viewMode == ViewCompact {
return ViewCompact
}

if m.width > 0 && m.width < 40 {
return ViewTiny
}
if m.height > 0 && m.height < 6 {
return ViewTiny
}
if m.width > 0 && m.width < 60 {
return ViewCompact
}
if m.height > 0 && m.height < 16 {
return ViewMini
}
if m.height > 0 && m.height < 22 {
return ViewCompact
}
return ViewHero
mode, _ := pickViewModeAndContent(m)
return mode
}

func (m *Model) updateRollingMax(down, up float64) {
Expand Down
76 changes: 76 additions & 0 deletions internal/ui/resize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package ui

import (
"strings"
"testing"
"time"

"github.com/programmersd21/flow/internal/history"
)

// Regression test for a bug where shrinking the terminal produced a
// dashboard taller than the actual viewport, causing the terminal to
// scroll and clip the top of the TUI (title/graphs) instead of adapting
// the view mode.
func TestViewNeverExceedsTerminalHeight(t *testing.T) {
widths := []int{30, 40, 50, 60, 70, 80, 100, 120}
heights := []int{4, 6, 10, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40, 50}

for _, w := range widths {
for _, h := range heights {
m := Model{
width: w,
height: h,
tracker: history.NewTracker(),
downHist: history.New(60),
upHist: history.New(60),
refreshInterval: time.Second,
lastSampleTime: time.Now(),
}

expectedMode := m.effectiveViewMode()
rawLines := dashboardLineCount(m, expectedMode)
if rawLines > h && expectedMode != ViewTiny {
t.Errorf("w=%d h=%d mode=%v: untruncated lines %d exceeds terminal height %d", w, h, expectedMode, rawLines, h)
}
}
}
}

// TestEffectiveViewModeFitsBeforeClamping guards against a regression where
// the height measurement used to pick a view mode read centerFrame's
// already-clamped output instead of the raw content: since the clamp
// truncates everything to the terminal height anyway, that made every
// candidate mode look like it "fit", so effectiveViewMode always picked
// Hero and just relied on the clamp to hide the overflow — defeating
// adaptive mode selection while still passing TestViewNeverExceedsTerminalHeight
// above. This test deliberately uses dashboardContentLines directly (not
// dashboardLineCount) as an independent ground truth, so it still catches
// the bug even if dashboardLineCount itself is the thing that regresses.
func TestEffectiveViewModeFitsBeforeClamping(t *testing.T) {
widths := []int{60, 70, 80, 100, 120}
heights := []int{16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40, 50}

for _, w := range widths {
for _, h := range heights {
m := Model{
width: w,
height: h,
tracker: history.NewTracker(),
downHist: history.New(60),
upHist: history.New(60),
refreshInterval: time.Second,
lastSampleTime: time.Now(),
}

mode := m.effectiveViewMode()
if mode == ViewTiny {
continue // single centered line, always fits trivially
}
content := strings.Join(dashboardContentLines(m, mode), "\n")
if got := strings.Count(content, "\n") + 1; got > h {
t.Errorf("w=%d h=%d: effectiveViewMode chose mode=%v needing %d pre-clamp lines, which doesn't fit (mode selection is not actually adaptive)", w, h, mode, got)
}
}
}
}
80 changes: 73 additions & 7 deletions internal/ui/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ const (
compactInnerMax = 68
)

func renderHero(m Model) string { return renderDashboard(m, ViewHero) }
func renderCompact(m Model) string { return renderDashboard(m, ViewCompact) }
func renderMini(m Model) string { return renderDashboard(m, ViewMini) }

func renderTiny(m Model) string {
downRatio := theme.SpeedRatio(m.dispDown, m.rollingMaxDown)
upRatio := theme.SpeedRatio(m.dispUp, m.rollingMaxUp)
Expand Down Expand Up @@ -258,7 +254,71 @@ func TitleRow(breathe float64) string {
return fmt.Sprintf("%s %s %s", dot, title, desc)
}

func renderDashboard(m Model, mode ViewMode) string {
// lineCount reports how many real terminal rows a set of content lines
// takes up. Elements can themselves be multi-line strings (e.g. a bordered
// panel is one element with embedded "\n"s), so join first and count real
// newlines rather than len(lines).
func lineCount(lines []string) int {
return strings.Count(strings.Join(lines, "\n"), "\n") + 1
}

// dashboardLineCount reports how many content lines the given mode actually
// needs at the model's current terminal size, BEFORE centerFrame pads or
// clamps it to fit the viewport. It must measure pre-clamp content, since
// measuring post-clamp output would always report "fits" (centerFrame's
// safety net truncates everything to the terminal height) and defeat mode
// selection entirely.
func dashboardLineCount(m Model, mode ViewMode) int {
return lineCount(dashboardContentLines(m, mode))
}

// pickViewModeAndContent chooses the best-fitting view mode for the current
// terminal size and returns it together with its already-built content
// lines, so the caller doesn't need to render the winning candidate a
// second time — building a mode's content re-renders braille graphs and
// styled panels, so doing that twice per frame (once to measure, once to
// display) would be wasted work. Returns nil lines for ViewTiny, which is
// rendered separately by renderTiny.
func pickViewModeAndContent(m Model) (ViewMode, []string) {
if m.viewMode == ViewTiny {
return ViewTiny, nil
}
if m.viewMode == ViewMini {
return ViewMini, dashboardContentLines(m, ViewMini)
}
if m.viewMode == ViewCompact {
return ViewCompact, dashboardContentLines(m, ViewCompact)
}

if m.width > 0 && m.width < 40 {
return ViewTiny, nil
}
if m.height > 0 && m.height < 6 {
return ViewTiny, nil
}

candidates := []ViewMode{ViewHero, ViewCompact, ViewMini}
if m.width > 0 && m.width < 60 {
candidates = []ViewMode{ViewCompact, ViewMini}
}

// Pick the richest mode whose actual rendered height still fits the
// terminal, instead of relying on hardcoded line-count guesses that
// drift out of sync with the real layout as panels/footers change.
if m.height > 0 {
for _, mode := range candidates {
lines := dashboardContentLines(m, mode)
if lineCount(lines) <= m.height {
return mode, lines
}
}
return ViewTiny, nil
}

return candidates[0], dashboardContentLines(m, candidates[0])
}

func dashboardContentLines(m Model, mode ViewMode) []string {
termW := m.width
if termW <= 0 {
termW = 80
Expand Down Expand Up @@ -433,8 +493,7 @@ func renderDashboard(m Model, mode ViewMode) string {
}
}

content := strings.Join(lines, "\n")
return centerFrame(content, termW, termH)
return lines
}

func renderPanel(title string, value string, peak string, peakPulse float64, graph string, width int, borderColor lipgloss.Color, isMini bool) string {
Expand Down Expand Up @@ -592,6 +651,13 @@ func centerFrame(content string, width, height int) string {
for len(out) < height {
out = append(out, "")
}
// Never emit more rows than the terminal actually has: keep the top
// (title/current values), drop the tail (footer/hints) if it still
// doesn't fit. Without this, content taller than the viewport gets
// silently scrolled by the terminal itself, cutting off the top instead.
if height > 0 && len(out) > height {
out = out[:height]
}

return strings.Join(out, "\n")
}
Expand Down