From 70379742a63cadeab63e510d283bcf75c4ca2e0c Mon Sep 17 00:00:00 2001 From: Juan Pablo Genovese Date: Tue, 12 May 2026 18:32:47 +0200 Subject: [PATCH] fix: resizeY grow path bloats Height via clear/ensureHeight feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grow branch of Screen.resizeY read v.Height fresh on every inner iteration as the offset for the next row to clear, but the very clear() call inside that loop goes through paint → ensureHeight, which mutates v.Height between iterations. Each new "row" therefore compounded its offset by row+1 per column, so growing a 24×40 terminal to 29×50 ended with v.Height=624 and len(v.Content)=1219. A trailing v.Height = h reset the field, but only after resizeX had already iterated the bloated Content and re-grown v.Height to match. Embedders that wrap midterm as a pane (terminal multiplexers, agent dashboards) saw the side-effect as their host renderer clipping content off-screen — the pane itself drew fine, only the chrome below it disappeared once the bloated Render output overflowed the host viewport. ensureHeight already does exactly what the loop intended: append a row to Content with all cells initialised to ' ', paint EmptyFormat on every cell via the canvas, tick Changes, increment v.Height — once per new row, no feedback loop. Letting it do the work directly keeps Content / Changes / Height / Canvas in lockstep with no per-cell redundancy. TestResizeGrowingHeightDoesNotBloatCanvas pins the size invariants (Height, Width, len(Content), len(Changes), per-row width, and the rendered line count) across four growth shapes, including the 24×40 → 29×50 original repro and a width-only-grow control case that was never affected. Pre-fix the height-growth cases failed with v.Height in the hundreds or hundreds of thousands; post-fix all four pass. --- screen.go | 23 +++++++++++++++-------- terminal_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/screen.go b/screen.go index 3bc95c4..38f1129 100644 --- a/screen.go +++ b/screen.go @@ -91,14 +91,21 @@ func (v *Screen) resizeY(h int) { v.MaxY = h - 1 } - if h > v.Height { - n := h - v.Height - for row := 0; row < n; row++ { - for col := 0; col < v.Width; col++ { - v.clear(v.Height+row, col, EmptyFormat) - } - } - } else if h < v.Height { + switch { + case h > v.Height: + // Grow: ensureHeight already appends empty rows, paints + // EmptyFormat on each new cell, and ticks Changes — exactly + // the per-row initialization this branch used to do + // cell-by-cell via clear(). The previous loop read v.Height + // fresh on every iteration as the row offset, and clear()'s + // internal ensureHeight call mutated v.Height between + // iterations, so each new row compounded the offset by + // row+1 per column (Height grew from 24 to 624 on a + // 24×40 → 29×50 resize). Letting ensureHeight do the work + // directly keeps the canvas, Content, Changes, and Height + // in lockstep. + v.ensureHeight(h - 1) + case h < v.Height: v.Content = v.Content[:h] v.Changes = v.Changes[:h] } diff --git a/terminal_test.go b/terminal_test.go index 9a7d978..fbb8835 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -202,6 +202,53 @@ func TestResizeGrowingHeightThenShrinkWidth(t *testing.T) { require.NoError(t, err) } +// TestResizeGrowingHeightDoesNotBloatCanvas pins the size invariants +// after a Resize() that grows the height. Before the fix to resizeY's +// grow path, the inner clear() call mutated v.Height while the outer +// loop was still using it as the offset for the next row, so each +// new row compounded the height by row+1 per column. Concretely, +// NewTerminal(24, 40) + Resize(29, 50) ended with Height=624 and +// len(Content)=1219 instead of 29 and 29. Embedders (terminal +// multiplexers, agent dashboards) saw the side-effect as their host +// renderer clipping content off-screen. +func TestResizeGrowingHeightDoesNotBloatCanvas(t *testing.T) { + cases := []struct { + startRows, startCols int + targetRows int + targetCols int + }{ + // Original reproduction: small initial size, modest grow, both axes. + {24, 40, 29, 50}, + // Grow only height. + {20, 80, 50, 80}, + // Grow only width (control — was never buggy, included for completeness). + {30, 60, 30, 120}, + // Grow then grow again from a state that previously couldn't survive + // a single Resize without bloat. + {24, 40, 100, 100}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("from_%dx%d_to_%dx%d", tc.startRows, tc.startCols, tc.targetRows, tc.targetCols), func(t *testing.T) { + vt := midterm.NewTerminal(tc.startRows, tc.startCols) + vt.Resize(tc.targetRows, tc.targetCols) + + require.Equal(t, tc.targetRows, vt.Height, "vt.Height should equal target rows") + require.Equal(t, tc.targetCols, vt.Width, "vt.Width should equal target cols") + require.Equal(t, tc.targetRows, len(vt.Content), "len(vt.Content) should equal target rows") + require.Equal(t, tc.targetRows, len(vt.Changes), "len(vt.Changes) should equal target rows") + for i, row := range vt.Content { + require.Equal(t, tc.targetCols, len(row), "Content row %d width should equal target cols", i) + } + + // Render must produce exactly target-rows lines so embedders + // don't see phantom rows past the visible area. + buf := new(bytes.Buffer) + require.NoError(t, vt.Render(buf)) + require.Equal(t, tc.targetRows, strings.Count(buf.String(), "\n")+1, "rendered line count should equal target rows") + }) + } +} + func TestInsertModePreservesShiftedContentAcrossLines(t *testing.T) { term := midterm.NewTerminal(24, 80) term.Raw = true