Skip to content

Commit 5c6ea1c

Browse files
feat: add scroll navigation, Clear, and AddMarker to Terminal
Add public Terminal methods for programmatic scroll navigation and viewport management, porting the upstream xterm.js CoreTerminal and headless Terminal APIs: - ScrollLines(disp) — scroll viewport by N lines - ScrollPages(pageCount) — scroll viewport by N pages - ScrollToTop() — scroll to top of scrollback - ScrollToBottom() — scroll to bottom (latest output) - ScrollToLine(line) — scroll to specific line - Clear() — clear viewport and scrollback - AddMarker(cursorYOffset) — create marker relative to cursor Fixes #21 Co-authored-by: Ona <no-reply@ona.com>
1 parent 54d979a commit 5c6ea1c

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

terminal.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,67 @@ func (t *Terminal) ScrollTop() int { return t.bufferService.Buffer().ScrollTop }
277277
// ScrollBottom returns the bottom of the scroll region (0-based).
278278
func (t *Terminal) ScrollBottom() int { return t.bufferService.Buffer().ScrollBottom }
279279

280+
// ScrollLines scrolls the viewport by disp lines (negative = up, positive = down).
281+
func (t *Terminal) ScrollLines(disp int) {
282+
t.bufferService.ScrollLines(disp, false)
283+
}
284+
285+
// ScrollPages scrolls the viewport by pageCount pages.
286+
func (t *Terminal) ScrollPages(pageCount int) {
287+
t.ScrollLines(pageCount * t.bufferService.Rows)
288+
}
289+
290+
// ScrollToTop scrolls the viewport to the top of the scrollback.
291+
func (t *Terminal) ScrollToTop() {
292+
t.ScrollLines(-t.bufferService.Buffer().YDisp)
293+
}
294+
295+
// ScrollToBottom scrolls the viewport to the bottom (latest output).
296+
func (t *Terminal) ScrollToBottom() {
297+
t.ScrollLines(t.bufferService.Buffer().YBase - t.bufferService.Buffer().YDisp)
298+
}
299+
300+
// ScrollToLine scrolls the viewport so that the given line is at the top.
301+
func (t *Terminal) ScrollToLine(line int) {
302+
t.ScrollLines(line - t.bufferService.Buffer().YDisp)
303+
}
304+
305+
// Clear clears the viewport and scrollback buffer, preserving the line the
306+
// cursor is on. Ported from xterm.js src/headless/Terminal.ts clear().
307+
func (t *Terminal) Clear() {
308+
buf := t.bufferService.Buffer()
309+
if buf.YBase == 0 && buf.Y == 0 {
310+
// Nothing to clear.
311+
return
312+
}
313+
314+
// Clear rows above the cursor.
315+
for i := buf.YBase + buf.Y - 1; i >= buf.YBase; i-- {
316+
line := buf.Lines.Get(i)
317+
if line != nil && line.GetTrimmedLength() != 0 {
318+
break
319+
}
320+
if i > buf.YBase+buf.Y-1 {
321+
continue
322+
}
323+
buf.Lines.Set(i, buf.GetBlankLine(nil, false))
324+
}
325+
326+
// Trim scrollback.
327+
buf.Lines.TrimStart(buf.YBase)
328+
buf.YBase = 0
329+
buf.YDisp = 0
330+
t.bufferService.IsUserScrolling = false
331+
332+
t.OnScrollEmitter.Fire(buf.YDisp)
333+
}
334+
335+
// AddMarker creates a marker at the cursor position plus the given offset.
336+
func (t *Terminal) AddMarker(cursorYOffset int) *Marker {
337+
buf := t.bufferService.Buffer()
338+
return buf.AddMarker(buf.YBase + buf.Y + cursorYOffset)
339+
}
340+
280341
// Scrollback returns the scrollback buffer size.
281342
func (t *Terminal) Scrollback() int { return t.optionsService.Options.Scrollback }
282343

terminal_test.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,3 +843,251 @@ func TestTerminalRegisterOscHandler(t *testing.T) {
843843
}
844844
}
845845

846+
// fillScrollback writes enough lines to the terminal to create scrollback content.
847+
func fillScrollback(term *Terminal, lineCount int) {
848+
for i := range lineCount {
849+
term.WriteString(fmt.Sprintf("line %d\r\n", i))
850+
}
851+
}
852+
853+
func TestTerminalScrollLines(t *testing.T) {
854+
t.Parallel()
855+
856+
type Expectation struct {
857+
YDisp int
858+
YBase int
859+
}
860+
861+
term := newTestTerminal(80, 5)
862+
fillScrollback(term, 20)
863+
864+
yBase := term.Buffer().YBase
865+
866+
// Scroll up 3 lines
867+
term.ScrollLines(-3)
868+
869+
got := Expectation{
870+
YDisp: term.Buffer().YDisp,
871+
YBase: term.Buffer().YBase,
872+
}
873+
expected := Expectation{
874+
YDisp: yBase - 3,
875+
YBase: yBase,
876+
}
877+
if diff := cmp.Diff(expected, got); diff != "" {
878+
t.Errorf("(-want +got):\n%s", diff)
879+
}
880+
}
881+
882+
func TestTerminalScrollLinesDown(t *testing.T) {
883+
t.Parallel()
884+
885+
term := newTestTerminal(80, 5)
886+
fillScrollback(term, 20)
887+
888+
// Scroll up then back down
889+
term.ScrollLines(-5)
890+
term.ScrollLines(5)
891+
892+
if term.Buffer().YDisp != term.Buffer().YBase {
893+
t.Errorf("YDisp = %d, want %d (YBase)", term.Buffer().YDisp, term.Buffer().YBase)
894+
}
895+
}
896+
897+
func TestTerminalScrollPages(t *testing.T) {
898+
t.Parallel()
899+
900+
term := newTestTerminal(80, 5)
901+
fillScrollback(term, 30)
902+
903+
yBase := term.Buffer().YBase
904+
905+
// Scroll up 2 pages (2 * 5 rows = 10 lines)
906+
term.ScrollPages(-2)
907+
908+
expected := yBase - 10
909+
if term.Buffer().YDisp != expected {
910+
t.Errorf("YDisp = %d, want %d", term.Buffer().YDisp, expected)
911+
}
912+
}
913+
914+
func TestTerminalScrollToTop(t *testing.T) {
915+
t.Parallel()
916+
917+
term := newTestTerminal(80, 5)
918+
fillScrollback(term, 20)
919+
920+
term.ScrollToTop()
921+
922+
if term.Buffer().YDisp != 0 {
923+
t.Errorf("YDisp = %d, want 0", term.Buffer().YDisp)
924+
}
925+
}
926+
927+
func TestTerminalScrollToBottom(t *testing.T) {
928+
t.Parallel()
929+
930+
term := newTestTerminal(80, 5)
931+
fillScrollback(term, 20)
932+
933+
// Scroll to top first, then back to bottom
934+
term.ScrollToTop()
935+
term.ScrollToBottom()
936+
937+
if term.Buffer().YDisp != term.Buffer().YBase {
938+
t.Errorf("YDisp = %d, want %d (YBase)", term.Buffer().YDisp, term.Buffer().YBase)
939+
}
940+
}
941+
942+
func TestTerminalScrollToLine(t *testing.T) {
943+
t.Parallel()
944+
945+
term := newTestTerminal(80, 5)
946+
fillScrollback(term, 20)
947+
948+
term.ScrollToLine(5)
949+
950+
if term.Buffer().YDisp != 5 {
951+
t.Errorf("YDisp = %d, want 5", term.Buffer().YDisp)
952+
}
953+
}
954+
955+
func TestTerminalScrollToLineClamps(t *testing.T) {
956+
t.Parallel()
957+
958+
term := newTestTerminal(80, 5)
959+
fillScrollback(term, 20)
960+
961+
// Scroll to a line beyond YBase — should clamp to YBase
962+
term.ScrollToLine(9999)
963+
964+
if term.Buffer().YDisp != term.Buffer().YBase {
965+
t.Errorf("YDisp = %d, want %d (YBase)", term.Buffer().YDisp, term.Buffer().YBase)
966+
}
967+
}
968+
969+
func TestTerminalScrollNoScrollback(t *testing.T) {
970+
t.Parallel()
971+
972+
term := newTestTerminal(80, 5)
973+
// No scrollback content — scrolling should be a no-op
974+
term.ScrollLines(-5)
975+
976+
if term.Buffer().YDisp != 0 {
977+
t.Errorf("YDisp = %d, want 0", term.Buffer().YDisp)
978+
}
979+
}
980+
981+
func TestTerminalClear(t *testing.T) {
982+
t.Parallel()
983+
984+
type Expectation struct {
985+
YBase int
986+
YDisp int
987+
IsUserScrolling bool
988+
}
989+
990+
term := newTestTerminal(80, 5)
991+
fillScrollback(term, 20)
992+
993+
// Scroll up to simulate user scrolling
994+
term.ScrollToTop()
995+
996+
term.Clear()
997+
998+
got := Expectation{
999+
YBase: term.Buffer().YBase,
1000+
YDisp: term.Buffer().YDisp,
1001+
IsUserScrolling: term.bufferService.IsUserScrolling,
1002+
}
1003+
expected := Expectation{
1004+
YBase: 0,
1005+
YDisp: 0,
1006+
IsUserScrolling: false,
1007+
}
1008+
if diff := cmp.Diff(expected, got); diff != "" {
1009+
t.Errorf("(-want +got):\n%s", diff)
1010+
}
1011+
}
1012+
1013+
func TestTerminalClearEmptyTerminal(t *testing.T) {
1014+
t.Parallel()
1015+
1016+
term := newTestTerminal(80, 5)
1017+
// Clear on empty terminal should be a no-op (no panic)
1018+
term.Clear()
1019+
1020+
if term.Buffer().YBase != 0 {
1021+
t.Errorf("YBase = %d, want 0", term.Buffer().YBase)
1022+
}
1023+
}
1024+
1025+
func TestTerminalClearFiresScrollEvent(t *testing.T) {
1026+
t.Parallel()
1027+
1028+
term := newTestTerminal(80, 5)
1029+
fillScrollback(term, 20)
1030+
1031+
scrollFired := false
1032+
term.OnScroll(func(int) { scrollFired = true })
1033+
1034+
term.Clear()
1035+
1036+
if !scrollFired {
1037+
t.Error("expected OnScroll to fire after Clear()")
1038+
}
1039+
}
1040+
1041+
func TestTerminalAddMarker(t *testing.T) {
1042+
t.Parallel()
1043+
1044+
term := newTestTerminal(80, 24)
1045+
term.WriteString("hello\r\nworld\r\n")
1046+
1047+
// Cursor is at row 2 (0-based), add marker at cursor position
1048+
marker := term.AddMarker(0)
1049+
if marker == nil {
1050+
t.Fatal("AddMarker returned nil")
1051+
}
1052+
1053+
expectedLine := term.Buffer().YBase + term.CursorY()
1054+
if marker.Line != expectedLine {
1055+
t.Errorf("marker.Line = %d, want %d", marker.Line, expectedLine)
1056+
}
1057+
}
1058+
1059+
func TestTerminalAddMarkerWithOffset(t *testing.T) {
1060+
t.Parallel()
1061+
1062+
term := newTestTerminal(80, 24)
1063+
term.WriteString("line1\r\nline2\r\nline3\r\n")
1064+
1065+
// Add marker 1 line above cursor
1066+
marker := term.AddMarker(-1)
1067+
if marker == nil {
1068+
t.Fatal("AddMarker returned nil")
1069+
}
1070+
1071+
expectedLine := term.Buffer().YBase + term.CursorY() - 1
1072+
if marker.Line != expectedLine {
1073+
t.Errorf("marker.Line = %d, want %d", marker.Line, expectedLine)
1074+
}
1075+
}
1076+
1077+
func TestTerminalScrollLinesFiresEvent(t *testing.T) {
1078+
t.Parallel()
1079+
1080+
term := newTestTerminal(80, 5)
1081+
fillScrollback(term, 20)
1082+
1083+
scrollEvents := 0
1084+
term.OnScroll(func(int) { scrollEvents++ })
1085+
1086+
term.ScrollLines(-3)
1087+
1088+
if scrollEvents != 1 {
1089+
t.Errorf("scroll events = %d, want 1", scrollEvents)
1090+
}
1091+
}
1092+
1093+

0 commit comments

Comments
 (0)