okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
8786532
main
1package uv 2 3import ( 4"bytes" 5"fmt" 6"image/color" 7"io" 8"strings" 9"testing" 10 11"github.com/charmbracelet/colorprofile" 12"github.com/charmbracelet/x/ansi" 13) 14 15func TestSimpleRendererOutput (t * testing.T ) { 16const w ,h = 5 ,3 17var buf bytes.Buffer 18r := NewTerminalRenderer (& buf , []string { 19"TERM=xterm-256color" ,// This will enable 256 colors for the renderer 20"COLORTERM=truecolor" ,// This will enable true color support for the renderer 21 }) 22 23r .SetFullscreen (true ) 24r .SetRelativeCursor (false ) 25r .SaveCursor () 26r .Erase () 27 28// r.SetTabStops(5) // Use tab character \t for cursor movements. 29// r.SetBackspace(true) // Use backspace character \b for cursor movements. 30// r.SetMapNewline(true) // Map newline characters to \r\n for proper line endings. 31r .Resize (w ,h ) 32 33cellbuf := NewRenderBuffer (5 ,3 ) 34// 'X', ' ', ' ', ' ', ' ' 35// ' ', 'X', ' ', ' ', ' ' 36// ' ', ' ', 'X', ' ', ' ' 37 38cell := Cell {Content :"X" ,Width :1 } 39cellbuf .SetCell (0 ,0 ,& cell ) 40cellbuf .SetCell (1 ,1 ,& cell ) 41cellbuf .SetCell (2 ,2 ,& cell ) 42 43r .Render (cellbuf ) 44if err := r .Flush ();err != nil { 45t .Fatalf ("failed to flush renderer: %v" ,err ) 46 } 47 48expected := "\x1b[H\x1b[2JX\nX\nX" 49if buf .String ()!= expected { 50t .Errorf ("expected output:\n%q\nbut got:\n%q" ,expected ,buf .String ()) 51 } 52} 53 54func TestInlineRendererOutput (t * testing.T ) { 55var buf bytes.Buffer 56r := NewTerminalRenderer (& buf , []string { 57"TERM=xterm-256color" ,// This will enable 256 colors for the renderer 58"COLORTERM=truecolor" ,// This will enable true color support for the renderer 59 }) 60 61r .SetRelativeCursor (true )// Use relative cursor movements. 62 63const physicalWidth ,physicalHeight = 80 ,24 // Terminal width 64const width ,height = 80 ,3 // Application width 65r .Resize (physicalWidth ,physicalHeight ) 66cellbuf := NewRenderBuffer (physicalWidth ,height ) 67 68for i ,r := range "Hello, World!" { 69cell := Cell {Content :string (r ),Width :1 } 70cellbuf .SetCell (i ,0 ,& cell ) 71 } 72 73r .Render (cellbuf ) 74if err := r .Flush ();err != nil { 75t .Fatalf ("failed to flush renderer: %v" ,err ) 76 } 77 78expected := "\rHello, World!" 79if buf .String ()!= expected { 80t .Errorf ("expected output:\n%q\nbut got:\n%q" ,expected ,buf .String ()) 81 } 82} 83 84func TestRendererColorProfile (t * testing.T ) { 85tests := []struct { 86name string 87profile colorprofile.Profile 88env []string 89expected colorprofile.Profile 90 }{ 91 { 92name :"truecolor" , 93profile :colorprofile .TrueColor , 94env : []string {"COLORTERM=truecolor" }, 95expected :colorprofile .TrueColor , 96 }, 97 { 98name :"256 color" , 99profile :colorprofile .ANSI256 , 100env : []string {"TERM=xterm-256color" }, 101expected :colorprofile .ANSI256 , 102 }, 103 { 104name :"16 color" , 105profile :colorprofile .ANSI , 106env : []string {"TERM=xterm" }, 107expected :colorprofile .ANSI , 108 }, 109 } 110 111for _ ,tt := range tests { 112t .Run (tt .name ,func (t * testing.T ) { 113var buf bytes.Buffer 114r := NewTerminalRenderer (& buf ,tt .env ) 115r .SetColorProfile (tt .profile ) 116 117// Test that the profile was set correctly by rendering a colored cell 118cellbuf := NewRenderBuffer (1 ,1 ) 119cell := Cell { 120Content :"X" , 121Width :1 , 122Style :Style {Fg : color.RGBA {R :255 ,G :0 ,B :0 ,A :255 }}, 123 } 124cellbuf .SetCell (0 ,0 ,& cell ) 125 126r .Render (cellbuf ) 127if err := r .Flush ();err != nil { 128t .Fatalf ("failed to flush renderer: %v" ,err ) 129 } 130 131// The output should contain color sequences appropriate for the profile 132output := buf .String () 133if ! strings .Contains (output ,"X" ) { 134t .Errorf ("expected output to contain 'X', got: %q" ,output ) 135 } 136 }) 137 } 138} 139 140func TestRendererPosition (t * testing.T ) { 141var buf bytes.Buffer 142r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 143 144// Initial position should be -1, -1 145x ,y := r .Position () 146if x != - 1 || y != - 1 { 147t .Errorf ("expected initial position (-1, -1), got (%d, %d)" ,x ,y ) 148 } 149 150// Set position 151r .SetPosition (5 ,10 ) 152x ,y = r .Position () 153if x != 5 || y != 10 { 154t .Errorf ("expected position (5, 10), got (%d, %d)" ,x ,y ) 155 } 156} 157 158func TestRendererMoveTo (t * testing.T ) { 159var buf bytes.Buffer 160r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 161 162r .MoveTo (5 ,3 ) 163if err := r .Flush ();err != nil { 164t .Fatalf ("failed to flush renderer: %v" ,err ) 165 } 166 167// Should contain cursor positioning sequence 168output := buf .String () 169if ! strings .Contains (output ,"\x1b[" ) { 170t .Errorf ("expected cursor positioning sequence in output: %q" ,output ) 171 } 172} 173 174func TestRendererWriteString (t * testing.T ) { 175var buf bytes.Buffer 176r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 177 178n ,err := r .WriteString ("Hello, World!" ) 179if err != nil { 180t .Fatalf ("failed to write string: %v" ,err ) 181 } 182 183if n != 13 { 184t .Errorf ("expected to write 13 bytes, wrote %d" ,n ) 185 } 186 187if err := r .Flush ();err != nil { 188t .Fatalf ("failed to flush renderer: %v" ,err ) 189 } 190 191output := buf .String () 192if ! strings .Contains (output ,"Hello, World!" ) { 193t .Errorf ("expected output to contain 'Hello, World!', got: %q" ,output ) 194 } 195} 196 197func TestRendererWrite (t * testing.T ) { 198var buf bytes.Buffer 199r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 200 201data := []byte ("Hello, World!" ) 202n ,err := r .Write (data ) 203if err != nil { 204t .Fatalf ("failed to write bytes: %v" ,err ) 205 } 206 207if n != len (data ) { 208t .Errorf ("expected to write %d bytes, wrote %d" ,len (data ),n ) 209 } 210 211if err := r .Flush ();err != nil { 212t .Fatalf ("failed to flush renderer: %v" ,err ) 213 } 214 215output := buf .String () 216if ! strings .Contains (output ,"Hello, World!" ) { 217t .Errorf ("expected output to contain 'Hello, World!', got: %q" ,output ) 218 } 219} 220 221func TestRendererRedraw (t * testing.T ) { 222var buf bytes.Buffer 223r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 224 225cellbuf := NewRenderBuffer (3 ,1 ) 226cell := Cell {Content :"X" ,Width :1 } 227cellbuf .SetCell (0 ,0 ,& cell ) 228 229// First render 230r .Render (cellbuf ) 231if err := r .Flush ();err != nil { 232t .Fatalf ("failed to flush renderer: %v" ,err ) 233 } 234 235firstOutput := buf .String () 236buf .Reset () 237 238// Redraw should force a full redraw 239r .Redraw (cellbuf ) 240if err := r .Flush ();err != nil { 241t .Fatalf ("failed to flush renderer: %v" ,err ) 242 } 243 244secondOutput := buf .String () 245 246// Both outputs should contain the cell content 247if ! strings .Contains (firstOutput ,"X" ) { 248t .Errorf ("expected first output to contain 'X', got: %q" ,firstOutput ) 249 } 250if ! strings .Contains (secondOutput ,"X" ) { 251t .Errorf ("expected second output to contain 'X', got: %q" ,secondOutput ) 252 } 253} 254 255func TestRendererErase (t * testing.T ) { 256var buf bytes.Buffer 257r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 258 259cellbuf := NewRenderBuffer (3 ,1 ) 260cell := Cell {Content :"X" ,Width :1 } 261cellbuf .SetCell (0 ,0 ,& cell ) 262 263// Mark for erase 264r .Erase () 265 266// Render should perform a full clear 267r .Render (cellbuf ) 268if err := r .Flush ();err != nil { 269t .Fatalf ("failed to flush renderer: %v" ,err ) 270 } 271 272output := buf .String () 273if ! strings .Contains (output ,"X" ) { 274t .Errorf ("expected output to contain 'X', got: %q" ,output ) 275 } 276} 277 278func TestRendererResize (t * testing.T ) { 279var buf bytes.Buffer 280r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 281 282// Test resize 283r .Resize (80 ,24 ) 284 285// Should not crash and should handle the resize 286cellbuf := NewRenderBuffer (80 ,24 ) 287r .Render (cellbuf ) 288if err := r .Flush ();err != nil { 289t .Fatalf ("failed to flush renderer: %v" ,err ) 290 } 291} 292 293func TestRendererPrependString (t * testing.T ) { 294var buf bytes.Buffer 295r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 296 297r .Resize (10 ,5 ) 298cellbuf := NewRenderBuffer (10 ,5 ) 299 300r .PrependString (cellbuf ,"Prepended line" ) 301r .Render (cellbuf ) 302if err := r .Flush ();err != nil { 303t .Fatalf ("failed to flush renderer: %v" ,err ) 304 } 305 306output := buf .String () 307if ! strings .Contains (output ,"Prepended line" ) { 308t .Errorf ("expected output to contain 'Prepended line', got: %q" ,output ) 309 } 310} 311 312func TestRendererPrependLines (t * testing.T ) { 313var buf bytes.Buffer 314r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 315 316r .Resize (10 ,5 ) 317cellbuf := NewRenderBuffer (10 ,5 ) 318 319// Create a line to prepend 320line := make (Line ,5 ) 321for i ,ch := range "Hello" { 322line [i ]= Cell {Content :string (ch ),Width :1 } 323 } 324 325r .PrependString (cellbuf ,line .Render ()) 326r .Render (cellbuf ) 327if err := r .Flush ();err != nil { 328t .Fatalf ("failed to flush renderer: %v" ,err ) 329 } 330 331output := buf .String () 332if ! strings .Contains (output ,"Hello" ) { 333t .Errorf ("expected output to contain 'Hello', got: %q" ,output ) 334 } 335} 336 337func TestRendererCapabilities (t * testing.T ) { 338tests := []struct { 339name string 340term string 341test func (* testing.T ,* TerminalRenderer ) 342 }{ 343 { 344name :"xterm capabilities" , 345term :"xterm-256color" , 346test :func (t * testing.T ,r * TerminalRenderer ) { 347// xterm should support all capabilities 348if ! r .caps .Contains (capCHA ) { 349t .Error ("expected xterm to support CHA" ) 350 } 351// NOTE: We have disabled HPA for xterm due to some terminals 352// not supporting it correctly i.e. Konsole. 353// if !r.caps.Contains(capHPA) { 354// t.Error("expected xterm to support HPA") 355// } 356 }, 357 }, 358 { 359name :"linux terminal capabilities" , 360term :"linux" , 361test :func (t * testing.T ,r * TerminalRenderer ) { 362// linux terminal has limited capabilities 363if ! r .caps .Contains (capVPA ) { 364t .Error ("expected linux to support VPA" ) 365 } 366if ! r .caps .Contains (capHPA ) { 367t .Error ("expected linux to support HPA" ) 368 } 369if r .caps .Contains (capREP ) { 370t .Error ("expected linux to not support REP" ) 371 } 372 }, 373 }, 374 } 375 376for _ ,tt := range tests { 377t .Run (tt .name ,func (t * testing.T ) { 378var buf bytes.Buffer 379r := NewTerminalRenderer (& buf , []string {"TERM=" + tt .term }) 380tt .test (t ,r ) 381 }) 382 } 383} 384 385func TestRendererTabStops (t * testing.T ) { 386var buf bytes.Buffer 387r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 388 389// Enable tab stops 390r .SetTabStops (8 ) 391 392// Test that tab stops are set 393cellbuf := NewRenderBuffer (20 ,1 ) 394r .Render (cellbuf ) 395if err := r .Flush ();err != nil { 396t .Fatalf ("failed to flush renderer: %v" ,err ) 397 } 398 399// Disable tab stops 400r .SetTabStops (- 1 ) 401 402r .Render (cellbuf ) 403if err := r .Flush ();err != nil { 404t .Fatalf ("failed to flush renderer: %v" ,err ) 405 } 406} 407 408func TestRendererBackspace (t * testing.T ) { 409var buf bytes.Buffer 410r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 411 412// Enable backspace optimization 413r .SetBackspace (true ) 414 415cellbuf := NewRenderBuffer (10 ,1 ) 416r .Render (cellbuf ) 417if err := r .Flush ();err != nil { 418t .Fatalf ("failed to flush renderer: %v" ,err ) 419 } 420 421// Disable backspace optimization 422r .SetBackspace (false ) 423 424r .Render (cellbuf ) 425if err := r .Flush ();err != nil { 426t .Fatalf ("failed to flush renderer: %v" ,err ) 427 } 428} 429 430func TestRendererMapNewline (t * testing.T ) { 431var buf bytes.Buffer 432r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 433 434// Enable newline mapping 435r .SetMapNewline (true ) 436 437cellbuf := NewRenderBuffer (10 ,2 ) 438r .Render (cellbuf ) 439if err := r .Flush ();err != nil { 440t .Fatalf ("failed to flush renderer: %v" ,err ) 441 } 442 443// Disable newline mapping 444r .SetMapNewline (false ) 445 446r .Render (cellbuf ) 447if err := r .Flush ();err != nil { 448t .Fatalf ("failed to flush renderer: %v" ,err ) 449 } 450} 451 452func TestRendererTouched (t * testing.T ) { 453var buf bytes.Buffer 454r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 455 456cellbuf := NewRenderBuffer (5 ,3 ) 457 458// Initially, no lines should be touched (empty buffer) 459touched := cellbuf .TouchedLines () 460if touched != 0 { 461t .Errorf ("expected 0 touched lines initially, got %d" ,touched ) 462 } 463 464// Mark some lines as touched by setting cells 465cell := Cell {Content :"X" ,Width :1 } 466cellbuf .SetCell (0 ,0 ,& cell ) 467cellbuf .SetCell (0 ,2 ,& cell ) 468 469// Should have touched lines where we set cells 470touched = cellbuf .TouchedLines () 471if touched != 2 { 472t .Errorf ("expected 2 touched lines after setting cells, got %d" ,touched ) 473 } 474 475// After rendering, the Touched method still counts all lines as touched 476// because the renderer sets all LineData to non-nil (even with FirstCell: -1, LastCell: -1) 477// This is the actual behavior of the renderer 478r .Render (cellbuf ) 479touched = cellbuf .TouchedLines () 480if touched != 3 { 481t .Errorf ("expected 3 touched lines after render (all lines have LineData), got %d" ,touched ) 482 } 483 484// But if we check the actual touched state by looking at FirstCell/LastCell 485actualTouched := 0 486for _ ,lineData := range cellbuf .Touched { 487if lineData != nil && (lineData .FirstCell != - 1 || lineData .LastCell != - 1 ) { 488actualTouched ++ 489 } 490 } 491if actualTouched != 0 { 492t .Errorf ("expected 0 actually touched lines after render, got %d" ,actualTouched ) 493 } 494} 495 496// Test wide character handling 497func TestRendererWideCharacters (t * testing.T ) { 498var buf bytes.Buffer 499r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 500 501cellbuf := NewRenderBuffer (10 ,1 ) 502 503// Test wide characters (emoji, CJK characters) 504wideChars := []string {"🌟" ,"中" ,"文" ,"字" } 505for i ,char := range wideChars { 506cell := Cell {Content :char ,Width :2 }// Wide characters typically have width 2 507cellbuf .SetCell (i * 2 ,0 ,& cell ) 508 } 509 510r .Render (cellbuf ) 511if err := r .Flush ();err != nil { 512t .Fatalf ("failed to flush renderer: %v" ,err ) 513 } 514 515output := buf .String () 516for _ ,char := range wideChars { 517if ! strings .Contains (output ,char ) { 518t .Errorf ("expected output to contain wide character '%s', got: %q" ,char ,output ) 519 } 520 } 521} 522 523// Test zero-width characters 524func TestRendererZeroWidthCharacters (t * testing.T ) { 525var buf bytes.Buffer 526r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 527 528cellbuf := NewRenderBuffer (5 ,1 ) 529 530// Test zero-width characters (combining marks, etc.) 531cell := Cell {Content :"a\u0301" ,Width :1 }// 'a' with combining acute accent 532cellbuf .SetCell (0 ,0 ,& cell ) 533 534// Zero-width cell 535zeroCell := Cell {Content :"\u200B" ,Width :0 }// Zero-width space 536cellbuf .SetCell (1 ,0 ,& zeroCell ) 537 538r .Render (cellbuf ) 539if err := r .Flush ();err != nil { 540t .Fatalf ("failed to flush renderer: %v" ,err ) 541 } 542 543output := buf .String () 544if ! strings .Contains (output ,"a\u0301" ) { 545t .Errorf ("expected output to contain combining character, got: %q" ,output ) 546 } 547} 548 549// Test styled text rendering 550func TestRendererStyledText (t * testing.T ) { 551var buf bytes.Buffer 552r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 553 554cellbuf := NewRenderBuffer (10 ,1 ) 555 556// Test various styles 557styles := []Style { 558 {Attrs :AttrBold }, 559 {Fg : color.RGBA {R :255 ,G :0 ,B :0 ,A :255 }}, 560 {Bg : color.RGBA {R :0 ,G :255 ,B :0 ,A :255 }}, 561 {Attrs :AttrBold ,Fg : color.RGBA {R :0 ,G :0 ,B :255 ,A :255 }}, 562 } 563 564for i ,style := range styles { 565cell := Cell {Content :"X" ,Width :1 ,Style :style } 566cellbuf .SetCell (i ,0 ,& cell ) 567 } 568 569r .Render (cellbuf ) 570if err := r .Flush ();err != nil { 571t .Fatalf ("failed to flush renderer: %v" ,err ) 572 } 573 574output := buf .String () 575// Should contain ANSI escape sequences for styling 576if ! strings .Contains (output ,"\x1b[" ) { 577t .Errorf ("expected output to contain ANSI escape sequences, got: %q" ,output ) 578 } 579} 580 581// Test hyperlink rendering 582func TestRendererHyperlinks (t * testing.T ) { 583var buf bytes.Buffer 584r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 585 586cellbuf := NewRenderBuffer (10 ,1 ) 587 588// Test hyperlink 589link := NewLink ("https://example.com" ) 590cell := Cell {Content :"link" ,Width :4 ,Link :link } 591cellbuf .SetCell (0 ,0 ,& cell ) 592 593r .Render (cellbuf ) 594if err := r .Flush ();err != nil { 595t .Fatalf ("failed to flush renderer: %v" ,err ) 596 } 597 598output := buf .String () 599if ! strings .Contains (output ,"link" ) { 600t .Errorf ("expected output to contain 'link', got: %q" ,output ) 601 } 602} 603 604// Moving between rows resets the pen, which closes any open hyperlink. Every 605// row the link covers has to reopen it, or only the first row stays clickable. 606func TestRendererHyperlinkReopensOnEachRow (t * testing.T ) { 607const w ,h = 4 ,2 608var buf bytes.Buffer 609r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 610r .SetColorProfile (colorprofile .TrueColor ) 611 612cellbuf := NewRenderBuffer (w ,h ) 613link := NewLink ("https://example.com" ) 614for y := range h { 615for x := range w { 616cellbuf .SetCell (x ,y ,& Cell {Content :"a" ,Width :1 ,Link :link }) 617 } 618 } 619 620r .Render (cellbuf ) 621if err := r .Flush ();err != nil { 622t .Fatalf ("failed to flush renderer: %v" ,err ) 623 } 624 625output := buf .String () 626opens := strings .Count (output ,ansi .SetHyperlink (link .URL ,link .Params )) 627if opens != h { 628t .Errorf ("expected the link to open once per row (%d), got %d: %q" ,h ,opens ,output ) 629 } 630} 631 632func TestRendererSwitchBuffer (t * testing.T ) { 633var buf bytes.Buffer 634r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 635 636// Start with small buffer 637cellbuf := NewRenderBuffer (5 ,3 ) 638cell := Cell {Content :"X" ,Width :1 } 639cellbuf .SetCell (0 ,0 ,& cell ) 640 641r .Render (cellbuf ) 642if err := r .Flush ();err != nil { 643t .Fatalf ("failed to flush renderer: %v" ,err ) 644 } 645 646// Resize to larger buffer 647largeBuf := NewRenderBuffer (10 ,6 ) 648largeBuf .SetCell (0 ,1 ,& cell )// Place at visible position 649 650r .Render (largeBuf ) 651if err := r .Flush ();err != nil { 652t .Fatalf ("failed to flush renderer: %v" ,err ) 653 } 654 655output := buf .String () 656// Home, draw X at (0,0); erase it, since the new frame's row 0 is blank; 657// newline, draw X at (0,1); pad cursor to row 5. 658expected := "\x1b[HX\r\x1b[K\nX\r\n\n\n\n" 659if output != expected { 660t .Errorf ("expected output after resize to be %q, got: %q" ,expected ,output ) 661 } 662} 663 664// Test relative cursor movement 665func TestRendererRelativeCursor (t * testing.T ) { 666var buf bytes.Buffer 667r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 668 669r .SetRelativeCursor (true ) 670 671cellbuf := NewRenderBuffer (10 ,3 ) 672cell := Cell {Content :"X" ,Width :1 } 673cellbuf .SetCell (5 ,1 ,& cell ) 674 675r .Render (cellbuf ) 676if err := r .Flush ();err != nil { 677t .Fatalf ("failed to flush renderer: %v" ,err ) 678 } 679 680output := buf .String () 681if ! strings .Contains (output ,"X" ) { 682t .Errorf ("expected output to contain 'X', got: %q" ,output ) 683 } 684 685// Test disabling relative cursor 686r .SetRelativeCursor (false ) 687buf .Reset () 688 689r .Render (cellbuf ) 690if err := r .Flush ();err != nil { 691t .Fatalf ("failed to flush renderer: %v" ,err ) 692 } 693} 694 695// Test logger functionality 696func TestRendererLogger (t * testing.T ) { 697var buf bytes.Buffer 698var logBuf bytes.Buffer 699 700r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 701 702// Create a simple logger 703logger := & testLogger {buf :& logBuf } 704r .SetLogger (logger ) 705 706cellbuf := NewRenderBuffer (3 ,1 ) 707cell := Cell {Content :"X" ,Width :1 } 708cellbuf .SetCell (0 ,0 ,& cell ) 709 710r .Render (cellbuf ) 711if err := r .Flush ();err != nil { 712t .Fatalf ("failed to flush renderer: %v" ,err ) 713 } 714 715// Should have logged output 716if logBuf .Len ()== 0 { 717t .Error ("expected logger to have recorded output" ) 718 } 719 720// Test removing logger 721r .SetLogger (nil ) 722logBuf .Reset () 723 724r .Render (cellbuf ) 725if err := r .Flush ();err != nil { 726t .Fatalf ("failed to flush renderer: %v" ,err ) 727 } 728 729// Should not have logged anything 730if logBuf .Len ()!= 0 { 731t .Error ("expected no logging after removing logger" ) 732 } 733} 734 735// Test scroll optimization 736func TestRendererScrollOptimization (t * testing.T ) { 737var buf bytes.Buffer 738r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 739 740r .SetFullscreen (true )// Scroll optimization is enabled in alt screen mode 741 742cellbuf := NewRenderBuffer (10 ,5 ) 743 744// Fill buffer with content 745for y := 0 ;y < 5 ;y ++ { 746for x := 0 ;x < 10 ;x ++ { 747cell := Cell {Content :string (rune ('A' + y )),Width :1 } 748cellbuf .SetCell (x ,y ,& cell ) 749 } 750 } 751 752// First render 753r .Render (cellbuf ) 754if err := r .Flush ();err != nil { 755t .Fatalf ("failed to flush renderer: %v" ,err ) 756 } 757 758buf .Reset () 759 760// Simulate scrolling by shifting content up 761newBuf := NewRenderBuffer (10 ,5 ) 762for y := 0 ;y < 4 ;y ++ { 763for x := 0 ;x < 10 ;x ++ { 764cell := Cell {Content :string (rune ('A' + y + 1 )),Width :1 } 765newBuf .SetCell (x ,y ,& cell ) 766 } 767 } 768// Add new line at bottom 769for x := 0 ;x < 10 ;x ++ { 770cell := Cell {Content :"F" ,Width :1 } 771newBuf .SetCell (x ,4 ,& cell ) 772 } 773 774r .Render (newBuf ) 775if err := r .Flush ();err != nil { 776t .Fatalf ("failed to flush renderer: %v" ,err ) 777 } 778 779output := buf .String () 780if ! strings .Contains (output ,"F" ) { 781t .Errorf ("expected output to contain new content 'F', got: %q" ,output ) 782 } 783} 784 785// Test multiple prepend operations 786func TestRendererMultiplePrepends (t * testing.T ) { 787var buf bytes.Buffer 788r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 789 790r .Resize (20 ,10 ) 791cellbuf := NewRenderBuffer (20 ,10 ) 792 793// Prepend multiple strings 794r .PrependString (cellbuf ,"First line" ) 795r .PrependString (cellbuf ,"Second line" ) 796 797// Prepend multiple lines 798line1 := make (Line ,10 ) 799line2 := make (Line ,10 ) 800for i ,ch := range "Third line" { 801if i < len (line1 ) { 802line1 [i ]= Cell {Content :string (ch ),Width :1 } 803 } 804 } 805for i ,ch := range "Fourth lin" { 806if i < len (line2 ) { 807line2 [i ]= Cell {Content :string (ch ),Width :1 } 808 } 809 } 810 811r .PrependString (cellbuf ,strings .Join ([]string {line1 .Render (),line2 .Render ()},"\n" )) 812 813r .Render (cellbuf ) 814if err := r .Flush ();err != nil { 815t .Fatalf ("failed to flush renderer: %v" ,err ) 816 } 817 818output := buf .String () 819expectedStrings := []string {"First line" ,"Second line" ,"Third line" ,"Fourth lin" } 820for _ ,expected := range expectedStrings { 821if ! strings .Contains (output ,expected ) { 822t .Errorf ("expected output to contain '%s', got: %q" ,expected ,output ) 823 } 824 } 825} 826 827// Test error conditions and edge cases 828func TestRendererEdgeCases (t * testing.T ) { 829var buf bytes.Buffer 830r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 831 832// Test with empty buffer 833emptyBuf := NewRenderBuffer (0 ,0 ) 834r .Render (emptyBuf ) 835if err := r .Flush ();err != nil { 836t .Fatalf ("failed to flush renderer with empty buffer: %v" ,err ) 837 } 838 839// Test with nil cells 840cellbuf := NewRenderBuffer (3 ,3 ) 841cellbuf .SetCell (1 ,1 ,nil )// Set nil cell 842 843r .Render (cellbuf ) 844if err := r .Flush ();err != nil { 845t .Fatalf ("failed to flush renderer with nil cells: %v" ,err ) 846 } 847 848// Test with very large buffer 849largeBuf := NewRenderBuffer (1000 ,1000 ) 850cell := Cell {Content :"X" ,Width :1 } 851largeBuf .SetCell (999 ,999 ,& cell ) 852 853r .Render (largeBuf ) 854if err := r .Flush ();err != nil { 855t .Fatalf ("failed to flush renderer with large buffer: %v" ,err ) 856 } 857} 858 859// Test terminal-specific optimizations 860func TestRendererTerminalOptimizations (t * testing.T ) { 861tests := []struct { 862name string 863term string 864test func (* testing.T ,* TerminalRenderer ) 865 }{ 866 { 867name :"alacritty optimizations" , 868term :"alacritty" , 869test :func (t * testing.T ,r * TerminalRenderer ) { 870// Alacritty has specific capability limitations 871if r .caps .Contains (capCHT ) { 872t .Error ("expected alacritty to not support CHT" ) 873 } 874 }, 875 }, 876 { 877name :"screen optimizations" , 878term :"screen" , 879test :func (t * testing.T ,r * TerminalRenderer ) { 880// Screen terminal has specific limitations 881if r .caps .Contains (capREP ) { 882t .Error ("expected screen to not support REP" ) 883 } 884 }, 885 }, 886 { 887name :"tmux optimizations" , 888term :"tmux" , 889test :func (t * testing.T ,r * TerminalRenderer ) { 890// tmux should support most capabilities 891if ! r .caps .Contains (capVPA ) { 892t .Error ("expected tmux to support VPA" ) 893 } 894 }, 895 }, 896 } 897 898for _ ,tt := range tests { 899t .Run (tt .name ,func (t * testing.T ) { 900var buf bytes.Buffer 901r := NewTerminalRenderer (& buf , []string {"TERM=" + tt .term }) 902tt .test (t ,r ) 903 }) 904 } 905} 906 907// Test cursor movement optimizations 908func TestRendererCursorMovementOptimizations (t * testing.T ) { 909var buf bytes.Buffer 910r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 911 912// Test tab optimization 913r .SetTabStops (8 ) 914cellbuf := NewRenderBuffer (20 ,1 ) 915 916// Place content at tab stops 917cell := Cell {Content :"X" ,Width :1 } 918cellbuf .SetCell (8 ,0 ,& cell )// First tab stop 919cellbuf .SetCell (16 ,0 ,& cell )// Second tab stop 920 921r .Render (cellbuf ) 922if err := r .Flush ();err != nil { 923t .Fatalf ("failed to flush renderer: %v" ,err ) 924 } 925 926output := buf .String () 927if ! strings .Contains (output ,"X" ) { 928t .Errorf ("expected output to contain 'X', got: %q" ,output ) 929 } 930} 931 932// Test backspace optimization 933func TestRendererBackspaceOptimization (t * testing.T ) { 934var buf bytes.Buffer 935r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 936 937r .SetBackspace (true ) 938cellbuf := NewRenderBuffer (10 ,1 ) 939 940// Place content that would benefit from backspace optimization 941cell := Cell {Content :"X" ,Width :1 } 942cellbuf .SetCell (5 ,0 ,& cell ) 943 944// Move cursor to position that would use backspace 945r .MoveTo (8 ,0 ) 946r .MoveTo (3 ,0 )// Should use backspace to move left 947 948r .Render (cellbuf ) 949if err := r .Flush ();err != nil { 950t .Fatalf ("failed to flush renderer: %v" ,err ) 951 } 952 953output := buf .String () 954if ! strings .Contains (output ,"X" ) { 955t .Errorf ("expected output to contain 'X', got: %q" ,output ) 956 } 957} 958 959// Test newline mapping 960func TestRendererNewlineMapping (t * testing.T ) { 961var buf bytes.Buffer 962r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 963 964r .SetMapNewline (true ) 965r .SetRelativeCursor (true ) 966 967cellbuf := NewRenderBuffer (10 ,3 ) 968cell := Cell {Content :"X" ,Width :1 } 969cellbuf .SetCell (0 ,0 ,& cell ) 970cellbuf .SetCell (0 ,1 ,& cell ) 971cellbuf .SetCell (0 ,2 ,& cell ) 972 973r .Render (cellbuf ) 974if err := r .Flush ();err != nil { 975t .Fatalf ("failed to flush renderer: %v" ,err ) 976 } 977 978output := buf .String () 979// Should contain newlines for multi-line content 980if ! strings .Contains (output ,"X" ) { 981t .Errorf ("expected output to contain 'X', got: %q" ,output ) 982 } 983} 984 985// Test underline styles 986func TestRendererUnderlineStyles (t * testing.T ) { 987var buf bytes.Buffer 988r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 989 990cellbuf := NewRenderBuffer (10 ,1 ) 991 992// Test different underline styles 993styles := []Style { 994 {Underline :UnderlineStyleSingle }, 995 {Underline :UnderlineStyleDouble }, 996 {Underline :UnderlineStyleCurly }, 997 {Underline :UnderlineStyleDotted }, 998 {Underline :UnderlineStyleDashed }, 999 } 1000 1001for i ,style := range styles { 1002if i < cellbuf .Width () { 1003cell := Cell {Content :"U" ,Width :1 ,Style :style } 1004cellbuf .SetCell (i ,0 ,& cell ) 1005 } 1006 } 1007 1008r .Render (cellbuf ) 1009if err := r .Flush ();err != nil { 1010t .Fatalf ("failed to flush renderer: %v" ,err ) 1011 } 1012 1013output := buf .String () 1014if ! strings .Contains (output ,"U" ) { 1015t .Errorf ("expected output to contain 'U', got: %q" ,output ) 1016 } 1017} 1018 1019// Test italic and other text attributes 1020func TestRendererTextAttributes (t * testing.T ) { 1021var buf bytes.Buffer 1022r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1023 1024cellbuf := NewRenderBuffer (10 ,1 ) 1025 1026// Test various text attributes 1027styles := []Style { 1028 {Attrs :AttrItalic }, 1029 {Attrs :AttrFaint }, 1030 {Attrs :AttrBlink }, 1031 {Attrs :AttrReverse }, 1032 {Attrs :AttrStrikethrough }, 1033 } 1034 1035for i ,style := range styles { 1036if i < cellbuf .Width () { 1037cell := Cell {Content :"A" ,Width :1 ,Style :style } 1038cellbuf .SetCell (i ,0 ,& cell ) 1039 } 1040 } 1041 1042r .Render (cellbuf ) 1043if err := r .Flush ();err != nil { 1044t .Fatalf ("failed to flush renderer: %v" ,err ) 1045 } 1046 1047output := buf .String () 1048if ! strings .Contains (output ,"A" ) { 1049t .Errorf ("expected output to contain 'A', got: %q" ,output ) 1050 } 1051} 1052 1053// Test color downsampling 1054func TestRendererColorDownsampling (t * testing.T ) { 1055tests := []struct { 1056name string 1057profile colorprofile.Profile 1058 }{ 1059 {"TrueColor" ,colorprofile .TrueColor }, 1060 {"ANSI256" ,colorprofile .ANSI256 }, 1061 {"ANSI" ,colorprofile .ANSI }, 1062 {"Ascii" ,colorprofile .Ascii }, 1063 } 1064 1065for _ ,tt := range tests { 1066t .Run (tt .name ,func (t * testing.T ) { 1067var buf bytes.Buffer 1068r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1069r .SetColorProfile (tt .profile ) 1070 1071cellbuf := NewRenderBuffer (3 ,1 ) 1072 1073// Test with high-precision color that needs downsampling 1074cell := Cell { 1075Content :"C" , 1076Width :1 , 1077Style :Style {Fg : color.RGBA {R :123 ,G :234 ,B :45 ,A :255 }}, 1078 } 1079cellbuf .SetCell (0 ,0 ,& cell ) 1080 1081r .Render (cellbuf ) 1082if err := r .Flush ();err != nil { 1083t .Fatalf ("failed to flush renderer: %v" ,err ) 1084 } 1085 1086output := buf .String () 1087if ! strings .Contains (output ,"C" ) { 1088t .Errorf ("expected output to contain 'C', got: %q" ,output ) 1089 } 1090 }) 1091 } 1092} 1093 1094// Test phantom cursor handling 1095func TestRendererPhantomCursor (t * testing.T ) { 1096var buf bytes.Buffer 1097r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1098r .SetColorProfile (colorprofile .TrueColor ) 1099 1100r .SetFullscreen (true )// Use fullscreen rendering optimizations 1101r .SetRelativeCursor (false ) 1102cellbuf := NewRenderBuffer (5 ,3 ) 1103 1104// Fill the last column to trigger phantom cursor behavior 1105cell := Cell {Content :"X" ,Width :1 } 1106for y := 0 ;y < cellbuf .Height ();y ++ { 1107cellbuf .SetCell (4 ,y ,& cell )// Last column 1108 } 1109 1110r .Render (cellbuf ) 1111if err := r .Flush ();err != nil { 1112t .Fatalf ("failed to flush renderer: %v" ,err ) 1113 } 1114 1115output := buf .String () 1116expected := "\x1b[1;5HX\r\n\x1b[5GX\r\n\x1b[5G\x1b[?7lX\x1b[?7h" 1117if output != expected { 1118t .Errorf ("expected no output for phantom cursor case, got: %q" ,output ) 1119 } 1120} 1121 1122// Test line clearing optimizations 1123func TestRendererLineClearingOptimizations (t * testing.T ) { 1124var buf bytes.Buffer 1125r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1126 1127cellbuf := NewRenderBuffer (10 ,3 ) 1128 1129// Fill first line completely 1130cell := Cell {Content :"X" ,Width :1 } 1131for x := 0 ;x < 10 ;x ++ { 1132cellbuf .SetCell (x ,0 ,& cell ) 1133 } 1134 1135// First render 1136r .Render (cellbuf ) 1137if err := r .Flush ();err != nil { 1138t .Fatalf ("failed to flush renderer: %v" ,err ) 1139 } 1140 1141buf .Reset () 1142 1143// Clear the line by creating new buffer with empty line 1144newBuf := NewRenderBuffer (10 ,3 ) 1145// Only set one cell, leaving the rest empty 1146newBuf .SetCell (0 ,0 ,& cell ) 1147 1148r .Render (newBuf ) 1149if err := r .Flush ();err != nil { 1150t .Fatalf ("failed to flush renderer: %v" ,err ) 1151 } 1152 1153output := buf .String () 1154if ! strings .Contains (output ,"X" ) { 1155t .Errorf ("expected output to contain 'X', got: %q" ,output ) 1156 } 1157} 1158 1159// Test repeat character optimization 1160func TestRendererRepeatCharacterOptimization (t * testing.T ) { 1161var buf bytes.Buffer 1162r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1163 1164cellbuf := NewRenderBuffer (20 ,1 ) 1165 1166// Fill with repeated characters that should trigger REP optimization 1167cell := Cell {Content :"A" ,Width :1 } 1168for x := 0 ;x < 15 ;x ++ { 1169cellbuf .SetCell (x ,0 ,& cell ) 1170 } 1171 1172r .Render (cellbuf ) 1173if err := r .Flush ();err != nil { 1174t .Fatalf ("failed to flush renderer: %v" ,err ) 1175 } 1176 1177output := buf .String () 1178if ! strings .Contains (output ,"A" ) { 1179t .Errorf ("expected output to contain 'A', got: %q" ,output ) 1180 } 1181} 1182 1183// Test erase character optimization 1184func TestRendererEraseCharacterOptimization (t * testing.T ) { 1185var buf bytes.Buffer 1186r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1187 1188cellbuf := NewRenderBuffer (20 ,1 ) 1189 1190// Add some non-space content first 1191cell := Cell {Content :"A" ,Width :1 } 1192cellbuf .SetCell (0 ,0 ,& cell ) 1193 1194// Fill with spaces that should trigger ECH optimization 1195spaceCell := Cell {Content :" " ,Width :1 } 1196for x := 5 ;x < 15 ;x ++ { 1197cellbuf .SetCell (x ,0 ,& spaceCell ) 1198 } 1199 1200r .Render (cellbuf ) 1201if err := r .Flush ();err != nil { 1202t .Fatalf ("failed to flush renderer: %v" ,err ) 1203 } 1204 1205// The output should use erase character optimization for consecutive spaces 1206output := buf .String () 1207// Just verify it doesn't crash and produces some output with our content 1208if ! strings .Contains (output ,"A" ) { 1209t .Errorf ("expected output to contain 'A', got: %q" ,output ) 1210 } 1211} 1212 1213func TestRendererUpdates (t * testing.T ) { 1214cases := []struct { 1215name string 1216frames []string 1217expected []string // expected ANSI escape sequence after each frame 1218 }{ 1219 { 1220name :"simple style change" , 1221frames : []string { 1222"A" , 1223"\x1b[1mA" , 1224 }, 1225expected : []string { 1226"\rA" , 1227"\r\x1b[1mA\x1b[m" , 1228 }, 1229 }, 1230 { 1231name :"style and link change" , 1232frames : []string {"A" ,"\x1b[31m\x1b]8;;https://example.com\x1b\\A\x1b]8;;\x1b\\" },// red + link 1233expected : []string { 1234"\rA" , 1235"\r\x1b[31m\x1b]8;;https://example.com\aA\x1b[m\x1b]8;;\a" , 1236 }, 1237 }, 1238 { 1239// Covers comparing stored downsampled colors vs new true color styles 1240// See commit 75d1e37ff1bb 1241name :"the same true color style frames" , 1242frames : []string { 1243" \x1b[38;2;255;128;0mABC\n DEF" ,// orange 1244" \x1b[38;2;255;128;0mABC\n DEF" ,// orange 1245" \x1b[38;2;255;128;0mABC\n DEF" ,// orange 1246 }, 1247expected : []string { 1248"\r \x1b[38;5;208mABC\x1b[m\r\n\x1b[38;5;208m DEF\x1b[m" , 1249"" , 1250"" , 1251 }, 1252 }, 1253 } 1254 1255for _ ,tc := range cases { 1256t .Run (tc .name ,func (t * testing.T ) { 1257var buf bytes.Buffer 1258r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" ,"TTY_FORCE=1" }) 1259t .Logf ("Profile: %v" ,r .profile ) 1260r .SetRelativeCursor (true )// Use absolute cursor movements since we're drawing fullscreen 1261 1262scr := NewScreenBuffer (5 ,3 ) 1263for i ,frameStr := range tc .frames { 1264NewStyledString (frameStr ).Draw (scr ,scr .Bounds ()) 1265r .Render (scr .RenderBuffer ) 1266if err := r .Flush ();err != nil { 1267t .Fatalf ("failed to flush renderer: %v" ,err ) 1268 } 1269 1270output := buf .String () 1271expected := tc .expected [i ] 1272if output != expected { 1273t .Errorf ("frame %d: expected output %q, got %q" ,i ,expected ,output ) 1274 } 1275 1276buf .Reset () 1277 } 1278 }) 1279 } 1280} 1281 1282func TestRendererPrependOneLine (t * testing.T ) { 1283var buf bytes.Buffer 1284r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1285 1286r .Resize (10 ,5 ) 1287cellbuf := NewScreenBuffer (10 ,5 ) 1288NewStyledString ("This-is-a ." ).Draw (cellbuf ,cellbuf .Bounds ()) 1289r .Render (cellbuf .RenderBuffer ) 1290if err := r .Flush ();err != nil { 1291t .Fatalf ("failed to flush renderer: %v" ,err ) 1292 } 1293 1294NewStyledString ("This-is-a ." ).Draw (cellbuf ,cellbuf .Bounds ()) 1295r .PrependString (cellbuf .RenderBuffer ,"Prepended-a-new-line" ) 1296r .Render (cellbuf .RenderBuffer ) 1297if err := r .Flush ();err != nil { 1298t .Fatalf ("failed to flush renderer: %v" ,err ) 1299 } 1300 1301output := buf .String () 1302expected := "\x1b[HThis-is-a\r\n\n\n\n\n\n\x1b[H\x1b[2LPrepended-a-new-line\r\n" 1303if output != expected { 1304t .Errorf ("expected output to be %q, got: %q" ,expected ,output ) 1305 } 1306} 1307 1308func TestRendererEnterExitAltScreen (t * testing.T ) { 1309var buf bytes.Buffer 1310r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1311cellbuf := NewRenderBuffer (3 ,3 ) 1312 1313// Simulate cursor change 1314r .MoveTo (1 ,1 ) 1315 1316// Save cursor to compare later 1317cur := r .cur 1318if cur .X != 1 || cur .Y != 1 { 1319t .Errorf ("expected cursor to be at (1,1), got (%d,%d)" ,cur .X ,cur .Y ) 1320 } 1321 1322// Enter alt screen 1323r .EnterAltScreen () 1324 1325// Ensure we render before processing further 1326r .Render (cellbuf ) 1327 1328// Check fullscreen is enabled 1329if ! r .Fullscreen () { 1330t .Errorf ("expected fullscreen to be enabled in alt screen mode" ) 1331 } 1332 1333// Check cursor position reset 1334if r .cur .X != 0 || r .cur .Y != 0 { 1335t .Errorf ("expected cursor to be reset to (0,0) in alt screen mode, got (%d,%d)" ,r .cur .X ,r .cur .Y ) 1336 } 1337 1338// Exit alt screen 1339r .ExitAltScreen () 1340 1341// Check relative cursor are disabled 1342if ! r .flags .Contains (tRelativeCursor ) { 1343t .Errorf ("expected relative cursor to be enabled after exiting alt screen mode" ) 1344 } 1345 1346// Check cursor position restored 1347if r .cur .X != 1 || r .cur .Y != 1 { 1348t .Errorf ("expected cursor to be restored to (1,1) after exiting alt screen mode, got (%d,%d)" ,r .cur .X ,r .cur .Y ) 1349 } 1350 1351if err := r .Flush ();err != nil { 1352t .Fatalf ("failed to flush renderer: %v" ,err ) 1353 } 1354 1355output := buf .String () 1356expected := "\x1b[2;2H\x1b[?1049h\x1b[H\x1b[2J\x1b[?1049l" 1357if output != expected { 1358t .Errorf ("expected output to be %q, got: %q" ,expected ,output ) 1359 } 1360} 1361 1362// Helper type for testing logger 1363type testLogger struct { 1364buf * bytes.Buffer 1365} 1366 1367func (l * testLogger )Printf (format string ,args ... interface {}) { 1368l .buf .WriteString ("LOG: " ) 1369l .buf .WriteString (format ) 1370l .buf .WriteByte ('\n' ) 1371} 1372 1373// Steady-state render of a small changed region, no resize. The common 1374// per-frame path. 1375func BenchmarkRenderFrame (b * testing.B ) { 1376r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 1377r .SetFullscreen (true ) 1378buf := NewScreenBuffer (80 ,24 ) 1379text := NewStyledString (strings .Repeat ("x" ,79 )) 1380b .ResetTimer () 1381for i := 0 ;i < b .N ;i ++ { 1382text .Draw (buf ,Rect (0 ,i % 24 ,80 ,1 )) 1383r .Render (buf .RenderBuffer ) 1384 } 1385} 1386 1387// Render across alternating grow/shrink resizes, exercising the early curbuf 1388// resize and the forced clear on shrink. The buffers are built up front so the 1389// measurement is the renderer's resize handling and not ScreenBuffer.Resize 1390// reallocating a grid every iteration. 1391func BenchmarkRenderResize (b * testing.B ) { 1392sizes := [][2 ]int {{100 ,30 }, {60 ,20 }, {120 ,40 }, {80 ,24 }} 1393bufs := make ([]ScreenBuffer ,len (sizes )) 1394for i ,sz := range sizes { 1395bufs [i ]= NewScreenBuffer (sz [0 ],sz [1 ]) 1396 } 1397 1398r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 1399r .SetFullscreen (true ) 1400text := NewStyledString (strings .Repeat ("x" ,40 )) 1401text .Draw (bufs [0 ],Rect (0 ,0 ,sizes [0 ][0 ],1 )) 1402r .Render (bufs [0 ].RenderBuffer ) 1403 1404b .ResetTimer () 1405for i := 0 ;i < b .N ;i ++ { 1406n := i % len (sizes ) 1407sz ,buf := sizes [n ],bufs [n ] 1408r .Resize (sz [0 ],sz [1 ]) 1409text .Draw (buf ,Rect (0 ,0 ,sz [0 ],1 )) 1410r .Render (buf .RenderBuffer ) 1411 } 1412} 1413 1414// Resizes that report the size the terminal already is, with a frame shorter 1415// than the screen. Applications are told the size on a schedule rather than only 1416// when it changes, so this is the steady state, not an edge case: a duplicate 1417// SIGWINCH, or a handler that reports on every frame. 1418// 1419// The measurement is how little a resize that changed nothing costs. Comparing 1420// the reported size against the model instead of against the last report makes 1421// this repaint the whole screen every iteration, which the other resize 1422// benchmark cannot see because it always resizes to exactly the frame size. 1423func BenchmarkResizeSteadyShortFrame (b * testing.B ) { 1424r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 1425r .SetFullscreen (true ) 1426r .Resize (80 ,24 ) 1427 1428buf := NewScreenBuffer (80 ,23 )// one row shorter than the screen 1429text := NewStyledString (strings .Repeat ("x" ,40 )) 1430text .Draw (buf ,Rect (0 ,0 ,80 ,1 )) 1431r .Render (buf .RenderBuffer ) 1432if err := r .Flush ();err != nil { 1433b .Fatalf ("failed to flush renderer: %v" ,err ) 1434 } 1435 1436b .ResetTimer () 1437for i := 0 ;i < b .N ;i ++ { 1438r .Resize (80 ,24 ) 1439text .Draw (buf ,Rect (0 ,i % 23 ,80 ,1 )) 1440r .Render (buf .RenderBuffer ) 1441if err := r .Flush ();err != nil { 1442b .Fatalf ("failed to flush renderer: %v" ,err ) 1443 } 1444 } 1445} 1446 1447// A resize invalidates the renderer's cursor model so the next move is 1448// absolute. In relative cursor mode there is no absolute move, and -1 there 1449// means "first move, assume the origin", so invalidating would assert a 1450// position rather than forget one and every later row would land low. 1451func TestRendererInlineResizeKeepsCursorModel (t * testing.T ) { 1452var buf bytes.Buffer 1453r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1454r .SetRelativeCursor (true ) 1455r .Resize (80 ,24 ) 1456 1457cellbuf := NewRenderBuffer (80 ,3 ) 1458for y := range 3 { 1459cellbuf .SetCell (0 ,y ,& Cell {Content :"a" ,Width :1 }) 1460 } 1461r .Render (cellbuf ) 1462if err := r .Flush ();err != nil { 1463t .Fatalf ("failed to flush renderer: %v" ,err ) 1464 } 1465buf .Reset () 1466 1467// A resize event that changes nothing, as a SIGWINCH handler would send. 1468r .Resize (80 ,24 ) 1469cellbuf .SetCell (0 ,0 ,& Cell {Content :"b" ,Width :1 }) 1470r .Render (cellbuf ) 1471if err := r .Flush ();err != nil { 1472t .Fatalf ("failed to flush renderer: %v" ,err ) 1473 } 1474 1475// Cursor is on row 2 after the first render, so reaching row 0 has to move 1476// up. Without the two-row move the "b" lands on row 2. 1477expected := "\r\x1b[2Ab" 1478if output := buf .String ();output != expected { 1479t .Errorf ("expected output after resize to be %q, got: %q" ,expected ,output ) 1480 } 1481} 1482 1483// A shrink forces a full repaint so the renderer does not diff against a model 1484// the terminal has reflowed underneath it. Inline mode shares the screen with 1485// whatever came before, so it keeps the narrower partial clear instead. 1486func TestRendererInlineShrinkClearsPartially (t * testing.T ) { 1487var buf bytes.Buffer 1488r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1489r .SetRelativeCursor (true ) 1490r .Resize (80 ,24 ) 1491 1492cellbuf := NewRenderBuffer (80 ,3 ) 1493for y := range 3 { 1494cellbuf .SetCell (0 ,y ,& Cell {Content :"a" ,Width :1 }) 1495 } 1496r .Render (cellbuf ) 1497if err := r .Flush ();err != nil { 1498t .Fatalf ("failed to flush renderer: %v" ,err ) 1499 } 1500buf .Reset () 1501 1502// The application gives up a row. 1503cellbuf .Resize (80 ,2 ) 1504r .Resize (80 ,24 ) 1505cellbuf .SetCell (0 ,1 ,& Cell {Content :"b" ,Width :1 }) 1506r .Render (cellbuf ) 1507if err := r .Flush ();err != nil { 1508t .Fatalf ("failed to flush renderer: %v" ,err ) 1509 } 1510 1511// Erase from row 2, the row the frame gave up, then up to row 1 to write the 1512// cell that changed. The erase no longer reaches into the frame, so row 1 is 1513// written because the application drew into it, not to put it back. 1514expected := "\r\x1b[J\x1bMb\r" 1515if output := buf .String ();output != expected { 1516t .Errorf ("expected output after shrink to be %q, got: %q" ,expected ,output ) 1517 } 1518} 1519 1520// The rows an inline frame gives up have to be erased whether or not the width 1521// moved at the same time. A terminal that changes width rewraps what it holds, 1522// which spreads the abandoned rows further than the model can account for 1523// rather than tidying them away, so a width change is the case that needs the 1524// erase most. 1525// 1526// The fuzzer found this one the first time it was allowed to draw inline 1527// frames: a row painted outside the new frame survived a resize that shrank the 1528// screen and widened it in the same step. 1529func TestRendererInlineShrinkErasesAcrossAWidthChange (t * testing.T ) { 1530var buf bytes.Buffer 1531r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1532r .SetRelativeCursor (true ) 1533r .Resize (6 ,10 ) 1534 1535cellbuf := NewRenderBuffer (6 ,4 ) 1536cellbuf .SetCell (0 ,2 ,& Cell {Content :"a" ,Width :1 }) 1537r .Render (cellbuf ) 1538if err := r .Flush ();err != nil { 1539t .Fatalf ("failed to flush renderer: %v" ,err ) 1540 } 1541buf .Reset () 1542 1543// Two rows shorter and much wider, the shape of a terminal resize the 1544// application reflowed its view for. 1545cellbuf .Resize (23 ,2 ) 1546r .Resize (23 ,10 ) 1547cellbuf .SetCell (0 ,0 ,& Cell {Content :"b" ,Width :1 }) 1548r .Render (cellbuf ) 1549if err := r .Flush ();err != nil { 1550t .Fatalf ("failed to flush renderer: %v" ,err ) 1551 } 1552 1553if out := buf .String ();! strings .Contains (out ,ansi .EraseScreenBelow ) { 1554t .Errorf ("shrink should erase the rows the frame gave up, got: %q" ,out ) 1555 } 1556} 1557 1558// That erase starts at the last row of the new frame, so it takes that row with 1559// it on the way down. The row still belongs to the frame and still holds what 1560// it held before, and the application has no reason to draw into a row it did 1561// not change, so nothing marks it for the diff loop to visit. 1562// 1563// The result is residue in reverse: not old content surviving, but current 1564// content erased and never put back. 1565func TestRendererInlineShrinkLeavesItsOwnRowsAlone (t * testing.T ) { 1566var buf bytes.Buffer 1567r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1568r .SetRelativeCursor (true ) 1569r .Resize (6 ,10 ) 1570 1571cellbuf := NewRenderBuffer (6 ,8 ) 1572cellbuf .SetCell (0 ,1 ,& Cell {Content :"a" ,Width :1 }) 1573r .Render (cellbuf ) 1574if err := r .Flush ();err != nil { 1575t .Fatalf ("failed to flush renderer: %v" ,err ) 1576 } 1577buf .Reset () 1578 1579// Six rows shorter. Row 1 survives the shrink and is not drawn into. 1580cellbuf .Resize (6 ,2 ) 1581r .Render (cellbuf ) 1582if err := r .Flush ();err != nil { 1583t .Fatalf ("failed to flush renderer: %v" ,err ) 1584 } 1585 1586out := buf .String () 1587if ! strings .Contains (out ,ansi .EraseScreenBelow ) { 1588t .Fatalf ("expected the shrink to erase below the frame, got: %q" ,out ) 1589 } 1590if strings .Contains (out ,"a" ) { 1591t .Errorf ("row 1 survives the shrink, so the erase should have left it alone " + 1592"rather than taking it and painting it back: %q" ,out ) 1593 } 1594} 1595 1596// A frame can collapse to nothing, and a buffer resized to zero rows reports 1597// zero columns too, so the erase below it has no row of its own to start from. 1598// Starting one row higher would reach above the frame, into rows that belong to 1599// whatever shared the screen first: a shell's output, another frame, scrollback. 1600// The renderer never wrote them and does not get to erase them. 1601func TestRendererInlineCollapseStaysBelowItsOrigin (t * testing.T ) { 1602var buf bytes.Buffer 1603r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1604r .SetRelativeCursor (true ) 1605r .Resize (10 ,20 ) 1606 1607cellbuf := NewRenderBuffer (10 ,4 ) 1608cellbuf .SetCell (0 ,0 ,& Cell {Content :"a" ,Width :1 }) 1609r .Render (cellbuf ) 1610if err := r .Flush ();err != nil { 1611t .Fatalf ("failed to flush renderer: %v" ,err ) 1612 } 1613buf .Reset () 1614 1615cellbuf .Resize (10 ,0 ) 1616r .Render (cellbuf ) 1617if err := r .Flush ();err != nil { 1618t .Fatalf ("failed to flush renderer: %v" ,err ) 1619 } 1620 1621// Cursor up from the frame's first row would leave the frame entirely, and 1622// the erase that follows would take the row above with it. 1623if out := buf .String ();strings .Contains (out ,ansi .CUU1 )|| strings .Contains (out ,"\x1b[1A" ) { 1624t .Errorf ("collapsing the frame moved above its first row: %q" ,out ) 1625 } 1626if _ ,y := r .Position ();y < 0 { 1627t .Errorf ("collapsing the frame left the cursor model at row %d" ,y ) 1628 } 1629} 1630 1631// The same shrink, but through the full-erase path an application takes when it 1632// knows the frame changed shape. The erase covers from the cursor to the end of 1633// the screen, so where the cursor is decides how much of the old frame it 1634// reaches, and the cursor is still on the last row of the frame that just ended. 1635// 1636// Clamping the remembered row to the new frame's height leaves the model 1637// claiming the cursor is already at the top. The move up is then computed as 1638// nothing, the erase runs from the bottom of the old frame, and every row above 1639// it survives into the new one. 1640func TestRendererInlineShrinkErasesFromTheTop (t * testing.T ) { 1641var buf bytes.Buffer 1642r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1643r .SetRelativeCursor (true ) 1644r .Resize (80 ,24 ) 1645 1646cellbuf := NewRenderBuffer (80 ,3 ) 1647for y := range 3 { 1648cellbuf .SetCell (0 ,y ,& Cell {Content :"a" ,Width :1 }) 1649 } 1650r .Render (cellbuf ) 1651if err := r .Flush ();err != nil { 1652t .Fatalf ("failed to flush renderer: %v" ,err ) 1653 } 1654buf .Reset () 1655 1656// Two rows shorter, painted from scratch rather than diffed. 1657r .Erase () 1658cellbuf .Touched = nil 1659cellbuf .Resize (80 ,1 ) 1660cellbuf .Clear () 1661cellbuf .SetCell (0 ,0 ,& Cell {Content :"b" ,Width :1 }) 1662r .Render (cellbuf ) 1663if err := r .Flush ();err != nil { 1664t .Fatalf ("failed to flush renderer: %v" ,err ) 1665 } 1666 1667// Up two rows from row 2, erase what the old frame left, draw row 0. 1668expected := "\r\x1b[2A\x1b[Jb\r" 1669if output := buf .String ();output != expected { 1670t .Errorf ("expected output after shrink to be %q, got: %q" ,expected ,output ) 1671 } 1672} 1673 1674// Rows added by a grow have to be diffed like any other. The model is resized 1675// before the diff loop runs so the loop walks them; otherwise content drawn 1676// into a new row never reaches the terminal. 1677// 1678// The grow repaints the screen rather than diffing it. The terminal fills rows 1679// it gains from its own scrollback, and those lines arrive at the top and push 1680// the rest down, so no row keeps its meaning across the resize. 1681func TestRendererGrowPaintsNewRows (t * testing.T ) { 1682var buf bytes.Buffer 1683r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1684r .SetFullscreen (true ) 1685r .Resize (10 ,3 ) 1686 1687cellbuf := NewRenderBuffer (10 ,3 ) 1688cellbuf .SetCell (0 ,0 ,& Cell {Content :"A" ,Width :1 }) 1689r .Render (cellbuf ) 1690if err := r .Flush ();err != nil { 1691t .Fatalf ("failed to flush renderer: %v" ,err ) 1692 } 1693buf .Reset () 1694 1695cellbuf .Resize (10 ,6 ) 1696r .Resize (10 ,6 ) 1697cellbuf .SetCell (0 ,5 ,& Cell {Content :"Z" ,Width :1 })// a row the grow added 1698r .Render (cellbuf ) 1699if err := r .Flush ();err != nil { 1700t .Fatalf ("failed to flush renderer: %v" ,err ) 1701 } 1702 1703expected := "\x1b[H\x1b[2JA\r\x1b[6dZ" 1704if output := buf .String ();output != expected { 1705t .Errorf ("expected output after grow to be %q, got: %q" ,expected ,output ) 1706 } 1707} 1708 1709// A fullscreen shrink scrolls the terminal to keep the cursor visible, moving 1710// every row the model has an opinion about. The next render has to repaint 1711// rather than diff against a model the terminal moved underneath it, and 1712// growing back does not undo the move. 1713// 1714// [TerminalRenderer.Render] catches a shrink it can see in the buffer 1715// dimensions. This one it cannot: the screen shrinks and grows back with no 1716// render in between, so only the resize itself can report it. 1717func TestRendererFullscreenShrinkRepaints (t * testing.T ) { 1718var buf bytes.Buffer 1719r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1720r .SetFullscreen (true ) 1721r .Resize (10 ,4 ) 1722 1723cellbuf := NewRenderBuffer (10 ,4 ) 1724cellbuf .SetCell (0 ,2 ,& Cell {Content :"a" ,Width :1 }) 1725r .Render (cellbuf ) 1726if err := r .Flush ();err != nil { 1727t .Fatalf ("failed to flush renderer: %v" ,err ) 1728 } 1729buf .Reset () 1730 1731r .Resize (10 ,2 ) 1732r .Resize (10 ,4 ) 1733r .Render (cellbuf ) 1734if err := r .Flush ();err != nil { 1735t .Fatalf ("failed to flush renderer: %v" ,err ) 1736 } 1737 1738if out := buf .String ();! strings .Contains (out ,ansi .EraseEntireScreen ) { 1739t .Errorf ("shrink should force a repaint, got: %q" ,out ) 1740 } 1741} 1742 1743// Losing columns is the same story told sideways: the terminal clips or 1744// rewraps every row to fit the narrower screen, and the model records none of 1745// it. Widening back restores the columns but not the content, so the model 1746// still claims cells the terminal no longer shows. 1747// 1748// The fuzzer found this one as residue: a row of clusters painted at the old 1749// width, narrowed and widened with no render in between, and the tail of the 1750// row still on screen afterwards. 1751func TestRendererFullscreenNarrowRepaints (t * testing.T ) { 1752var buf bytes.Buffer 1753r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1754r .SetFullscreen (true ) 1755r .Resize (20 ,2 ) 1756 1757cellbuf := NewRenderBuffer (20 ,2 ) 1758for x := range 20 { 1759cellbuf .SetCell (x ,0 ,& Cell {Content :"a" ,Width :1 }) 1760 } 1761r .Render (cellbuf ) 1762if err := r .Flush ();err != nil { 1763t .Fatalf ("failed to flush renderer: %v" ,err ) 1764 } 1765buf .Reset () 1766 1767r .Resize (8 ,2 ) 1768r .Resize (20 ,2 ) 1769r .Render (cellbuf ) 1770if err := r .Flush ();err != nil { 1771t .Fatalf ("failed to flush renderer: %v" ,err ) 1772 } 1773 1774if out := buf .String ();! strings .Contains (out ,ansi .EraseEntireScreen ) { 1775t .Errorf ("narrowing should force a repaint, got: %q" ,out ) 1776 } 1777} 1778 1779// The repaint a resize forces belongs to resizes that changed something. An 1780// application is free to draw a frame smaller than the screen, so comparing the 1781// reported size against the model would differ on every call and repaint the 1782// screen each time the renderer was told a size it already knew. A duplicate 1783// SIGWINCH costs nothing and a steady screen stays quiet. 1784func TestRendererResizeLatchesOnlyRealChanges (t * testing.T ) { 1785var buf bytes.Buffer 1786r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1787r .SetFullscreen (true ) 1788r .Resize (20 ,8 ) 1789 1790// A frame two rows shorter than the screen, so the model and the reported 1791// size disagree for as long as the application keeps drawing it. 1792scr := NewScreenBuffer (20 ,6 ) 1793NewStyledString ("hello" ).Draw (scr ,Rect (0 ,0 ,20 ,1 )) 1794r .Render (scr .RenderBuffer ) 1795if err := r .Flush ();err != nil { 1796t .Fatalf ("failed to flush renderer: %v" ,err ) 1797 } 1798 1799for range 3 { 1800buf .Reset () 1801r .Resize (20 ,8 )// the size it already is 1802r .Render (scr .RenderBuffer ) 1803if err := r .Flush ();err != nil { 1804t .Fatalf ("failed to flush renderer: %v" ,err ) 1805 } 1806if out := buf .String ();strings .Contains (out ,ansi .EraseEntireScreen ) { 1807t .Fatalf ("a resize that changed nothing repainted the screen: %q" ,out ) 1808 } 1809 } 1810 1811// A real change still latches, and still survives the grow back. 1812buf .Reset () 1813r .Resize (20 ,4 ) 1814r .Resize (20 ,8 ) 1815r .Render (scr .RenderBuffer ) 1816if err := r .Flush ();err != nil { 1817t .Fatalf ("failed to flush renderer: %v" ,err ) 1818 } 1819if out := buf .String ();! strings .Contains (out ,ansi .EraseEntireScreen ) { 1820t .Errorf ("a shrink and grow back should repaint, got: %q" ,out ) 1821 } 1822} 1823 1824// A drift-prone line is painted with autowrap off. A terminal that measures a 1825// cluster wider than the model does would otherwise run past the right margin, 1826// spilling the line onto the next row, or scrolling the whole screen when the 1827// line is the last one. Neither shows up in the model, so the residue outlives 1828// every later frame. 1829func TestRendererNoWrapOnDriftLine (t * testing.T ) { 1830render := func (content string ,width ,y int )string { 1831var buf bytes.Buffer 1832r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1833r .SetFullscreen (true ) 1834r .Resize (width ,2 ) 1835 1836scr := NewScreenBuffer (width ,2 ) 1837NewStyledString (content ).Draw (scr ,Rect (0 ,y ,width ,1 )) 1838r .Render (scr .RenderBuffer ) 1839if err := r .Flush ();err != nil { 1840t .Fatalf ("failed to flush renderer: %v" ,err ) 1841 } 1842return buf .String () 1843 } 1844 1845for _ ,y := range []int {0 ,1 } { 1846wide := render ("世界" ,6 ,y ) 1847if ! strings .Contains (wide ,ansi .ResetModeAutoWrap )|| ! strings .Contains (wide ,ansi .SetModeAutoWrap ) { 1848t .Errorf ("drift-prone row %d should paint with autowrap off, got: %q" ,y ,wide ) 1849 } 1850 } 1851 1852// A row of plain cells cannot overflow, so it pays nothing. 1853narrow := render ("abc" ,6 ,1 ) 1854if strings .Contains (narrow ,ansi .ResetModeAutoWrap ) { 1855t .Errorf ("plain row should not touch autowrap, got: %q" ,narrow ) 1856 } 1857} 1858 1859// The repaint a height change forces belongs to fullscreen mode and to actual 1860// changes. Inline frames are shorter than the terminal by design, and a render 1861// that resizes nothing has nothing to distrust. 1862func TestHeightRepaintScope (t * testing.T ) { 1863render := func (r * TerminalRenderer ,w ,h int ,mark string ) { 1864cb := NewRenderBuffer (w ,h ) 1865cb .SetCell (0 ,0 ,& Cell {Content :mark ,Width :1 }) 1866r .Render (cb ) 1867if err := r .Flush ();err != nil { 1868t .Fatalf ("Flush: %v" ,err ) 1869 } 1870 } 1871 1872t .Run ("inline mode leaves the height alone" ,func (t * testing.T ) { 1873var buf bytes.Buffer 1874r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1875r .SetRelativeCursor (true ) 1876render (r ,10 ,2 ,"a" ) 1877buf .Reset () 1878render (r ,10 ,5 ,"a" ) 1879if strings .Contains (buf .String (),ansi .EraseEntireScreen ) { 1880t .Errorf ("inline mode repainted on a height change: %q" ,buf .String ()) 1881 } 1882 }) 1883 1884t .Run ("a steady size does not repaint" ,func (t * testing.T ) { 1885var buf bytes.Buffer 1886r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1887r .SetFullscreen (true ) 1888render (r ,10 ,3 ,"a" ) 1889buf .Reset () 1890render (r ,10 ,3 ,"b" ) 1891if strings .Contains (buf .String (),ansi .EraseEntireScreen ) { 1892t .Errorf ("repainted without a resize: %q" ,buf .String ()) 1893 } 1894 }) 1895 1896t .Run ("degenerate sizes do not panic" ,func (t * testing.T ) { 1897for _ ,size := range [][2 ]int {{0 ,0 }, {10 ,0 }, {0 ,5 }, {1 ,1 }} { 1898var buf bytes.Buffer 1899r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1900r .SetFullscreen (true ) 1901render (r ,10 ,3 ,"a" ) 1902r .Resize (size [0 ],size [1 ]) 1903r .Render (NewRenderBuffer (size [0 ],size [1 ])) 1904if err := r .Flush ();err != nil { 1905t .Fatalf ("size %v: Flush: %v" ,size ,err ) 1906 } 1907 } 1908 }) 1909} 1910 1911// A carried sequence has to reach the terminal, and only when its cell changes. 1912// Re-sending it every frame would have an image protocol retransmit the image 1913// at the frame rate. 1914func TestPassThroughSequenceReachesTerminalOnce (t * testing.T ) { 1915const apc = "\x1b_Ga=T\x1b\\" 1916 1917var buf bytes.Buffer 1918r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1919r .SetFullscreen (true ) 1920 1921scr := NewScreenBuffer (10 ,2 ) 1922NewStyledString (apc + "hi" ).Draw (scr ,Rect (0 ,0 ,10 ,1 )) 1923r .Render (scr .RenderBuffer ) 1924if err := r .Flush ();err != nil { 1925t .Fatalf ("Flush: %v" ,err ) 1926 } 1927if n := strings .Count (buf .String (),apc );n != 1 { 1928t .Errorf ("first render sent the sequence %d times, want 1: %q" ,n ,buf .String ()) 1929 } 1930 1931buf .Reset () 1932r .Render (scr .RenderBuffer ) 1933if err := r .Flush ();err != nil { 1934t .Fatalf ("Flush: %v" ,err ) 1935 } 1936if n := strings .Count (buf .String (),apc );n != 0 { 1937t .Errorf ("an unchanged render re-sent the sequence %d times: %q" ,n ,buf .String ()) 1938 } 1939} 1940 1941// The ASCII short-circuit in lineHasDrift must never change its answer. 1942// Compare against the unoptimised definition over everything the conformance 1943// fuzzer can draw, plus every single byte. 1944func TestDriftShortCircuitAgreesWithFullCheck (t * testing.T ) { 1945full := func (m ansi.Method ,c * Cell )bool { 1946return c .Width > 1 || m .StringWidth (c .Content )!= ansi .StringWidth (c .Content ) 1947 } 1948 1949var contents []string 1950for b := 0 ;b < 256 ;b ++ { 1951contents = append (contents ,string ([]byte {byte (b )})) 1952 } 1953contents = append (contents , 1954"a" ,"\u4e16" ,"\uac00" ,"\U0001fae0" ,"e\u0301" ,"n\u0303" , 1955"\u2639\ufe0e" ,"\u2639\ufe0f" ,"\u2764\ufe0f" ,"\u2708\ufe0f" , 1956"\U0001f469\u200d\U0001f4bb" ,"\U0001f426\u200d\U0001f525" , 1957"\U0001f44d\U0001f3fd" ,"\U0001f44b\U0001f3ff" , 1958"\u26d3\ufe0f\u200d\U0001f4a5" ,"\U0001f1fa" ,"\U0001f1fa\U0001f1f8" , 1959"\U0001f1ef\U0001f1f5" ,"1\ufe0f\u20e3" ,"" ," " ,"\x1b_x\x1b\\a" , 1960 ) 1961 1962for _ ,m := range []ansi.Method {ansi .WcWidth ,ansi .GraphemeWidth } { 1963for _ ,content := range contents { 1964for _ ,w := range []int {0 ,1 ,2 } { 1965c := & Cell {Content :content ,Width :w } 1966line := Line {* c } 1967got := lineHasDrift (m ,line ) 1968want := false 1969if c .Width != 0 && len (c .Content )!= 0 { 1970want = full (m ,c ) 1971 } 1972if got != want { 1973t .Errorf ("method=%v content=%q width=%d: short-circuit says %v, full check says %v" , 1974m ,content ,w ,got ,want ) 1975 } 1976 } 1977 } 1978 } 1979} 1980 1981// A grapheme cluster at the right margin is written with autowrap on, even in 1982// the lower right corner where a plain cell is not. With autowrap off the 1983// terminal never advances past the margin, so it reads the combining 1984// codepoints as part of the cell to the left: the mark moves one column back 1985// and the base rune is left alone at the margin. Both reference emulators 1986// place the cluster correctly when autowrap stays on. 1987func TestRendererMarginClusterKeepsAutowrap (t * testing.T ) { 1988paint := func (content string ,y int )string { 1989var buf bytes.Buffer 1990r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 1991r .SetFullscreen (true ) 1992r .Resize (6 ,2 ) 1993 1994scr := NewScreenBuffer (6 ,2 ) 1995NewStyledString (content ).Draw (scr ,Rect (0 ,y ,6 ,1 )) 1996r .Render (scr .RenderBuffer ) 1997if err := r .Flush ();err != nil { 1998t .Fatalf ("failed to flush renderer: %v" ,err ) 1999 } 2000return buf .String () 2001 } 2002 2003// e + U+0301. Two codepoints, one column. 2004const cluster = "e\u0301" 2005 2006for _ ,y := range []int {0 ,1 } { 2007out := paint ("###" + cluster + cluster + cluster ,y ) 2008if strings .Contains (out ,ansi .ResetModeAutoWrap ) { 2009t .Errorf ("row %d: cluster at the margin painted with autowrap off: %q" ,y ,out ) 2010 } 2011 } 2012 2013// The corner still gets the autowrap dance when nothing can be split. 2014if out := paint ("######" ,1 );! strings .Contains (out ,ansi .ResetModeAutoWrap ) { 2015t .Errorf ("plain corner cell should paint with autowrap off, got: %q" ,out ) 2016 } 2017} 2018 2019// A hardware scroll moves every row in its range, including rows the 2020// application never drew into this frame. The diff loop only visits rows the 2021// application touched, so without marking the range those rows keep the model's 2022// old opinion of them and the content the scroll carried away never comes back. 2023// 2024// Here the scroll puts row 2 where row 0 belongs, which is what makes it worth 2025// doing, and takes row 1 off the top of the screen on the way. Row 1 has to be 2026// painted again even though nothing drew into it. 2027func TestRendererScrollRepaintsRowsItMoved (t * testing.T ) { 2028var buf bytes.Buffer 2029r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2030r .SetFullscreen (true ) 2031r .SetScrollOptim (true ) 2032r .Resize (4 ,8 ) 2033 2034scr := NewScreenBuffer (4 ,8 ) 2035NewStyledString ("aa" ).Draw (scr ,Rect (0 ,1 ,4 ,1 )) 2036NewStyledString ("bb" ).Draw (scr ,Rect (0 ,2 ,4 ,1 )) 2037r .Render (scr .RenderBuffer ) 2038if err := r .Flush ();err != nil { 2039t .Fatalf ("failed to flush renderer: %v" ,err ) 2040 } 2041buf .Reset () 2042 2043// Row 1 keeps its "aa" from the frame before and is not drawn into. 2044NewStyledString ("bb" ).Draw (scr ,Rect (0 ,0 ,4 ,1 )) 2045NewStyledString ("aa" ).Draw (scr ,Rect (0 ,2 ,4 ,1 )) 2046r .Render (scr .RenderBuffer ) 2047if err := r .Flush ();err != nil { 2048t .Fatalf ("failed to flush renderer: %v" ,err ) 2049 } 2050 2051out := buf .String () 2052if ! strings .Contains (out ,ansi .ScrollUp (2 )) { 2053t .Fatalf ("expected a hardware scroll, got: %q" ,out ) 2054 } 2055if n := strings .Count (out ,"aa" );n != 2 { 2056t .Errorf ("scrolled rows painted %d times, want 2 (rows 1 and 2): %q" ,n ,out ) 2057 } 2058} 2059 2060func TestRendererInlineNarrowRepaints (t * testing.T ) { 2061var buf bytes.Buffer 2062r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2063r .Resize (20 ,2 ) 2064 2065cellbuf := NewRenderBuffer (20 ,2 ) 2066for x := range 20 { 2067cellbuf .SetCell (x ,0 ,& Cell {Content :"a" ,Width :1 }) 2068 } 2069r .Render (cellbuf ) 2070if err := r .Flush ();err != nil { 2071t .Fatalf ("failed to flush renderer: %v" ,err ) 2072 } 2073buf .Reset () 2074 2075r .Resize (8 ,2 ) 2076r .Resize (20 ,2 ) 2077r .Render (cellbuf ) 2078if err := r .Flush ();err != nil { 2079t .Fatalf ("failed to flush renderer: %v" ,err ) 2080 } 2081 2082if out := buf .String ();! strings .Contains (out ,"a" ) { 2083t .Errorf ("a terminal that narrowed and grew back rewrapped the frame, so it has to be repainted, got: %q" ,out ) 2084 } 2085} 2086 2087func TestRendererCornerClusterLeavesNoPendingWrap (t * testing.T ) { 2088var buf bytes.Buffer 2089r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2090r .SetFullscreen (true ) 2091r .Resize (6 ,2 ) 2092 2093cellbuf := NewRenderBuffer (6 ,2 ) 2094for x := range 6 { 2095cellbuf .SetCell (x ,1 ,& Cell {Content :"#" ,Width :1 }) 2096 } 2097cellbuf .SetCell (5 ,1 ,& Cell {Content :"e\u0301" ,Width :1 }) 2098r .Render (cellbuf ) 2099if err := r .Flush ();err != nil { 2100t .Fatalf ("failed to flush renderer: %v" ,err ) 2101 } 2102 2103if x ,_ := r .Position ();x >= cellbuf .Width () { 2104t .Errorf ("a frame that ends in pending wrap on the last row scrolls the screen on the next print: cursor x=%d, width=%d" ,x ,cellbuf .Width ()) 2105 } 2106} 2107 2108// The renderer's account of the rows it disturbed itself is sized to the screen 2109// it is about to paint, so a row that cannot be painted cannot be damaged and no 2110// reader has to bounds-check the record. 2111func TestRendererDamageIsSizedToTheScreen (t * testing.T ) { 2112var buf bytes.Buffer 2113r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2114r .SetFullscreen (true ) 2115 2116tall := NewRenderBuffer (4 ,6 ) 2117tall .SetCell (0 ,5 ,& Cell {Content :"a" ,Width :1 }) 2118r .Render (tall ) 2119if got := len (r .damaged );got != 6 { 2120t .Errorf ("record holds %d rows for a 6-row screen, want 6" ,got ) 2121 } 2122 2123// Out of range either way is dropped, rather than growing the record to fit 2124// rows the screen does not have. 2125r .damage (- 3 ,2 ) 2126r .damage (6 ,99 ) 2127if got := len (r .damaged );got != 6 { 2128t .Errorf ("damage outside the screen resized the record to %d, want 6" ,got ) 2129 } 2130for y ,damaged := range r .damaged { 2131if damaged { 2132t .Errorf ("row %d is damaged, but every damage call was outside the screen" ,y ) 2133 } 2134 } 2135 2136// A shorter frame gets a shorter record, and carries nothing over from the 2137// taller frame it replaced. 2138r .damage (4 ,2 ) 2139short := NewRenderBuffer (4 ,2 ) 2140short .SetCell (0 ,0 ,& Cell {Content :"b" ,Width :1 }) 2141r .Render (short ) 2142if got := len (r .damaged );got != 2 { 2143t .Errorf ("record holds %d rows for a 2-row screen, want 2" ,got ) 2144 } 2145} 2146 2147// BenchmarkRenderFrameByHeight pins the shape of the win, not just its size. 2148// 2149// The cost of a frame used to scale with the height of the screen, because every 2150// render replaced one touch record per row. Reusing the records made it flat, and 2151// a benchmark at a single height cannot tell the two apart: run a few heights and 2152// the allocations per frame should not follow them. 2153func BenchmarkRenderFrameByHeight (b * testing.B ) { 2154for _ ,height := range []int {24 ,100 ,400 } { 2155b .Run (fmt .Sprintf ("h%d" ,height ),func (b * testing.B ) { 2156r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 2157r .SetFullscreen (true ) 2158buf := NewScreenBuffer (80 ,height ) 2159text := NewStyledString (strings .Repeat ("x" ,79 )) 2160b .ReportAllocs () 2161b .ResetTimer () 2162for i := 0 ;i < b .N ;i ++ { 2163text .Draw (buf ,Rect (0 ,i % height ,80 ,1 )) 2164r .Render (buf .RenderBuffer ) 2165 } 2166 }) 2167 } 2168} 2169 2170// TestRenderAllocationsDoNotFollowScreenHeight guards the property the record 2171// reuse bought, which a benchmark at one height cannot express. 2172// 2173// Every render used to replace one touch record per row, so the cost of a frame 2174// grew with the screen. A tall screen and a short one should now allocate the 2175// same amount per frame. 2176func TestRenderAllocationsDoNotFollowScreenHeight (t * testing.T ) { 2177perFrame := func (height int )float64 { 2178r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 2179r .SetFullscreen (true ) 2180buf := NewScreenBuffer (80 ,height ) 2181text := NewStyledString (strings .Repeat ("x" ,79 )) 2182 2183row := 0 2184draw := func () { 2185row = (row + 1 )% height 2186text .Draw (buf ,Rect (0 ,row ,80 ,1 )) 2187r .Render (buf .RenderBuffer ) 2188 } 2189 2190// Every row has to be drawn at least once first: a record is created on 2191// first touch, and counting that one-off as per-frame cost would make a 2192// tall screen look like it still scales. 2193for range height * 2 { 2194draw () 2195 } 2196return testing .AllocsPerRun (256 ,draw ) 2197 } 2198 2199short ,tall := perFrame (24 ),perFrame (400 ) 2200if tall > short + 8 { 2201t .Errorf ("a 400-row screen allocates %.0f per frame against %.0f for 24 rows, so the cost still follows the height" , 2202tall ,short ) 2203 } 2204} 2205 2206// A touch list shorter than the screen used to crash the scroll optimisation, 2207// which walked every row of the screen through it. An application can reach that 2208// state: the list is exported, so it can drop it and touch a single row. 2209// 2210// Enforced rather than tolerated, so the assertion is that the state cannot be 2211// built, not that a bounds check catches it. Every reader indexes the list by 2212// screen row, and there were five separate length checks standing in for this. 2213func TestRenderBufferTouchedCoversEveryRow (t * testing.T ) { 2214buf := NewRenderBuffer (5 ,4 ) 2215 2216if got := len (buf .Touched );got != buf .Height () { 2217t .Errorf ("a new buffer has %d touch entries for %d rows" ,got ,buf .Height ()) 2218 } 2219 2220// Dropping the list and touching one row restores the full length. 2221buf .Touched = nil 2222buf .SetCell (0 ,0 ,& Cell {Content :"a" ,Width :1 }) 2223if got := len (buf .Touched );got != buf .Height () { 2224t .Errorf ("after dropping the list and touching one row, %d entries for %d rows" ,got ,buf .Height ()) 2225 } 2226 2227// And so does touching a row of a screen that has since grown. 2228buf .Touched = buf .Touched [:1 ] 2229buf .Resize (7 ,9 ) 2230buf .SetCell (0 ,8 ,& Cell {Content :"c" ,Width :1 }) 2231if got := len (buf .Touched );got < buf .Height () { 2232t .Errorf ("after growing to %d rows and touching the last one, %d touch entries" ,buf .Height (),got ) 2233 } 2234} 2235 2236// The crash itself: a short list reaching the scroll optimisation, which indexed 2237// it by screen row and ran off the end. 2238func TestRendererSurvivesAShortTouchList (t * testing.T ) { 2239r := NewTerminalRenderer (io .Discard , []string {"TERM=xterm-256color" }) 2240r .SetFullscreen (true ) 2241r .SetScrollOptim (true ) 2242 2243cellbuf := NewRenderBuffer (5 ,4 ) 2244cellbuf .SetCell (0 ,0 ,& Cell {Content :"a" ,Width :1 }) 2245r .Render (cellbuf ) 2246if err := r .Flush ();err != nil { 2247t .Fatalf ("failed to flush renderer: %v" ,err ) 2248 } 2249 2250cellbuf .Touched = nil 2251cellbuf .SetCell (0 ,0 ,& Cell {Content :"b" ,Width :1 }) 2252r .Render (cellbuf ) 2253if err := r .Flush ();err != nil { 2254t .Fatalf ("failed to flush renderer: %v" ,err ) 2255 } 2256} 2257 2258// A frame an application hands over with no touches recorded still has to be 2259// repainted when the terminal resized under it. 2260// 2261// The touch list is exported, so an application is free to drop it, and a fresh 2262// list records nothing. Both make TouchedLines report zero, which is otherwise 2263// the renderer's signal that there is nothing to do. A resize latched in the 2264// meantime has to override that, or the rows the terminal rewrapped stay put. 2265func TestRendererRepaintsAResizeWithNoTouches (t * testing.T ) { 2266var buf bytes.Buffer 2267r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2268r .Resize (20 ,2 ) 2269 2270cellbuf := NewRenderBuffer (20 ,2 ) 2271for x := range 20 { 2272cellbuf .SetCell (x ,0 ,& Cell {Content :"a" ,Width :1 }) 2273 } 2274r .Render (cellbuf ) 2275if err := r .Flush ();err != nil { 2276t .Fatalf ("failed to flush renderer: %v" ,err ) 2277 } 2278 2279// The application drops the list, so the next frame reports no touches. 2280cellbuf .Touched = nil 2281if got := cellbuf .TouchedLines ();got != 0 { 2282t .Fatalf ("expected a frame reporting no touches, got %d" ,got ) 2283 } 2284 2285buf .Reset () 2286r .Resize (8 ,2 ) 2287r .Resize (20 ,2 ) 2288r .Render (cellbuf ) 2289if err := r .Flush ();err != nil { 2290t .Fatalf ("failed to flush renderer: %v" ,err ) 2291 } 2292 2293if out := buf .String ();! strings .Contains (out ,"a" ) { 2294t .Errorf ("a resize has to repaint even a frame reporting no touches, got: %q" ,out ) 2295 } 2296} 2297 2298// A frame that collapses and grows back to its old size between renders must 2299// erase what it last drew. The renderer sees no size change, so only the 2300// buffer can say those rows changed. 2301func TestRendererClearsRowsAFrameGotBack (t * testing.T ) { 2302var buf bytes.Buffer 2303r := NewTerminalRenderer (& buf , []string {"TERM=xterm-256color" }) 2304r .SetRelativeCursor (true ) 2305r .Resize (6 ,10 ) 2306 2307cellbuf := NewRenderBuffer (6 ,2 ) 2308cellbuf .SetCell (0 ,0 ,& Cell {Content :"a" ,Width :1 }) 2309r .Render (cellbuf ) 2310if err := r .Flush ();err != nil { 2311t .Fatalf ("failed to flush renderer: %v" ,err ) 2312 } 2313 2314cellbuf .Resize (6 ,0 ) 2315cellbuf .Resize (6 ,2 ) 2316cellbuf .SetCell (0 ,1 ,& Cell {Content :"b" ,Width :1 }) 2317buf .Reset () 2318r .Render (cellbuf ) 2319if err := r .Flush ();err != nil { 2320t .Fatalf ("failed to flush renderer: %v" ,err ) 2321 } 2322 2323if out := buf .String ();! strings .Contains (out ,ansi .EraseLineRight ) { 2324t .Errorf ("row 0 was never erased, wrote %q" ,out ) 2325 } 2326}