From 2cbfdf34e39f8ed86360f99fef88726bf3ebf9ca Mon Sep 17 00:00:00 2001 From: Keval Bhavsar Date: Mon, 25 May 2026 23:26:41 +0530 Subject: [PATCH] fix: Clamp textarea scroll offset to content size after deletion --- gui.go | 5 +++-- text_area.go | 25 +++++++++++++++++++++++++ view.go | 16 +++++++++++++--- view_test.go | 36 +++++++++++++++++++++++++++--------- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/gui.go b/gui.go index 0db418c6..e098895b 100644 --- a/gui.go +++ b/gui.go @@ -327,8 +327,9 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er if v.Editable { cursorX, cursorY := v.TextArea.GetCursorXY() - newViewCursorX, newOriginX := updatedCursorAndOrigin(0, v.InnerWidth(), cursorX) - newViewCursorY, newOriginY := updatedCursorAndOrigin(0, v.InnerHeight(), cursorY) + contentX, contentY := v.TextArea.GetContentDimensions() + newViewCursorX, newOriginX := updatedCursorAndOrigin(0, v.InnerWidth(), cursorX, contentX) + newViewCursorY, newOriginY := updatedCursorAndOrigin(0, v.InnerHeight(), cursorY, contentY) v.SetCursor(newViewCursorX, newViewCursorY) v.SetOrigin(newOriginX, newOriginY) diff --git a/text_area.go b/text_area.go index 9c88e983..bae421b0 100644 --- a/text_area.go +++ b/text_area.go @@ -36,6 +36,8 @@ type TextArea struct { clipboard string AutoWrap bool AutoWrapWidth int + maxContentX int // cached max x coordinate of content (rightmost position) + maxContentY int // cached max y coordinate of content (line count - 1) } func stringToTextAreaCells(str string) []TextAreaCell { @@ -248,6 +250,29 @@ func (self *TextArea) updateCells() { } self.cells, _ = contentToCells(self.content, width) + self.computeMaxDimensions() +} + +func (self *TextArea) computeMaxDimensions() { + self.maxContentX = 0 + self.maxContentY = 0 + for _, cell := range self.cells { + if cell.y > self.maxContentY { + self.maxContentY = cell.y + } + rightEdge := cell.x + cell.width + if rightEdge > self.maxContentX { + self.maxContentX = rightEdge + } + } + // If content ends with a newline, there's a blank line after it + if len(self.cells) > 0 && self.cells[len(self.cells)-1].char == "\n" { + self.maxContentY++ + } +} + +func (self *TextArea) GetContentDimensions() (int, int) { + return self.maxContentX, self.maxContentY } func (self *TextArea) typeCharacter(ch string) { diff --git a/view.go b/view.go index 16da0a38..715d5678 100644 --- a/view.go +++ b/view.go @@ -1730,15 +1730,16 @@ func (v *View) RenderTextArea() { cursorX, cursorY := v.TextArea.GetCursorXY() prevOriginX, prevOriginY := v.Origin() width, height := v.InnerWidth(), v.InnerHeight() + contentX, contentY := v.TextArea.GetContentDimensions() - newViewCursorX, newOriginX := updatedCursorAndOrigin(prevOriginX, width, cursorX) - newViewCursorY, newOriginY := updatedCursorAndOrigin(prevOriginY, height, cursorY) + newViewCursorX, newOriginX := updatedCursorAndOrigin(prevOriginX, width, cursorX, contentX) + newViewCursorY, newOriginY := updatedCursorAndOrigin(prevOriginY, height, cursorY, contentY) v.SetCursor(newViewCursorX, newViewCursorY) v.SetOrigin(newOriginX, newOriginY) } -func updatedCursorAndOrigin(prevOrigin int, size int, cursor int) (int, int) { +func updatedCursorAndOrigin(prevOrigin int, size int, cursor int, contentLen int) (int, int) { var newViewCursor int newOrigin := prevOrigin usableSize := size - 1 @@ -1753,6 +1754,15 @@ func updatedCursorAndOrigin(prevOrigin int, size int, cursor int) (int, int) { newViewCursor = cursor - prevOrigin } + // Prevent scrolling past the end of content + maxOrigin := contentLen - usableSize + if maxOrigin < 0 { + maxOrigin = 0 + } + if newOrigin > maxOrigin { + newOrigin = maxOrigin + } + return newViewCursor, newOrigin } diff --git a/view_test.go b/view_test.go index a7023be4..e43c2564 100644 --- a/view_test.go +++ b/view_test.go @@ -101,22 +101,40 @@ func TestUpdatedCursorAndOrigin(t *testing.T) { prevOrigin int size int cursor int + contentLen int expectedCursor int expectedOrigin int }{ - {0, 10, 0, 0, 0}, - {0, 10, 9, 9, 0}, - {0, 10, 10, 9, 1}, - {0, 10, 19, 9, 10}, - {0, 10, 20, 9, 11}, - {20, 10, 19, 0, 19}, - {20, 10, 25, 5, 20}, + // Original test cases with very long content (100 chars) + {0, 10, 0, 100, 0, 0}, + {0, 10, 9, 100, 9, 0}, + {0, 10, 10, 100, 9, 1}, + {0, 10, 19, 100, 9, 10}, + {0, 10, 20, 100, 9, 11}, + {20, 10, 19, 100, 0, 19}, + {20, 10, 25, 100, 5, 20}, + // content shorter than view should clamp origin to 0 + {5, 10, 5, 8, 0, 0}, + // content equal to usable size should allow origin at most 0 + {0, 10, 8, 9, 8, 0}, + // content just longer than usable size (10 content, size 10, usableSize 9) + {0, 10, 9, 10, 9, 0}, // cursor at last char, should be visible at origin 0 + {0, 10, 10, 10, 9, 1}, // cursor past end gets clamped, but so does origin to max 1 + {1, 10, 8, 10, 7, 1}, // already scrolled, cursor moves left + {1, 10, 9, 10, 8, 1}, // cursor at last position of content, origin stays at 1 + // content exactly fits after some scrolling + {5, 10, 5, 14, 0, 5}, // contentLen=14, usableSize=9, maxOrigin=5, cursor at origin + {5, 10, 9, 14, 4, 5}, // contentLen=14, cursor at 9, can show 5-13, origin stays at 5 + // cursor at end of just-longer content should show last chars + {0, 10, 20, 20, 9, 11}, // contentLen=20, cursor at 20, maxOrigin=11, shows 11-19 + // scrolled past middle, content shrinks, should clamp origin + {7, 10, 5, 15, 0, 5}, // was scrolled to 7, but contentLen=15 allows maxOrigin=6, cursor at 5 means origin=5 } for _, test := range tests { - cursor, origin := updatedCursorAndOrigin(test.prevOrigin, test.size, test.cursor) + cursor, origin := updatedCursorAndOrigin(test.prevOrigin, test.size, test.cursor, test.contentLen) assert.EqualValues(t, test.expectedCursor, cursor, "Cursor is wrong") - assert.EqualValues(t, test.expectedOrigin, origin, "Origin in wrong") + assert.EqualValues(t, test.expectedOrigin, origin, "Origin is wrong") } }