⋯ 51 hidden lines@@ -52,302 +52,440 @@ func lineText(s *Screen, y int) string { 52 52 // Printable ASCII / UTF-8 53 53 // --------------------------------------------------------------------------- 54 54 55 - func TestWritePrintable(t *testing.T) {56 - s := NewScreen(5, 3) 57 - s.Write([]byte("Hi" )) 58 - 59 - assertCell(t, s, 0, 0, 'H' ) 60 - assertCell(t, s, 1, 0, 'i' ) 61 - if s.Col != 2 || s.Row != 0 { 62 - t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 63 - } 64 - 65 - // Should have damage. 66 - d := s.Damage() 67 - if len(d) == 0 { 68 - t.Fatal("expected damage after writing" ) 69 - } 70 - } 71 - 72 - func TestWriteUTF8(t *testing.T) {73 - s := NewScreen(10, 3) 74 - s.Write([]byte("aäéø©" )) 75 - 76 - assertCell(t, s, 0, 0, 'a' ) 77 - assertCell(t, s, 1, 0, 'ä' ) 78 - assertCell(t, s, 2, 0, 'é' ) 79 - assertCell(t, s, 3, 0, 'ø' ) 80 - assertCell(t, s, 4, 0, '©' ) 81 - } 82 - 83 - func TestWritePrintableBeyondWidthWraps(t *testing.T) {84 - s := NewScreen(4, 2) 85 - s.Write([]byte("ABCDE" )) 86 - 87 - assertCell(t, s, 0, 0, 'A' ) 88 - assertCell(t, s, 1, 0, 'B' ) 89 - assertCell(t, s, 2, 0, 'C' ) 90 - assertCell(t, s, 3, 0, 'D' ) 91 - // E wrapped to next line. 92 - assertCell(t, s, 0, 1, 'E' ) 93 - } 94 - 95 - func TestWritePrintableBeyondScreenScrolls(t *testing.T) {96 - s := NewScreen(3, 2) 97 - // Fill both lines. 98 - s.Write([]byte("ABC" )) 99 - s.Write([]byte("DEF" )) 100 - // Row is now 1 (bottom). One more char should scroll. 101 - s.Write([]byte("G" )) 102 - 103 - // After scroll: old row 0 = "DEF", new row 1 = "G ". 104 - assertCell(t, s, 0, 0, 'D' ) 105 - assertCell(t, s, 1, 0, 'E' ) 106 - assertCell(t, s, 2, 0, 'F' ) 107 - assertCell(t, s, 0, 1, 'G' ) 108 - assertCell(t, s, 1, 1, ' ' ) 109 - 110 - // Check scroll damage. 111 - d := s.Damage() 112 - if !hasDamageKind(d, renderer.DamageScrollUp) { 113 - t.Error("expected scroll damage" ) 114 - } 115 - } 116 - 117 - // --------------------------------------------------------------------------- 118 - // Carriage Return 119 - // --------------------------------------------------------------------------- 120 - 121 - func TestCR(t *testing.T) {122 - s := NewScreen(10, 3) 123 - s.Write([]byte("Hello" )) 124 - // CR moves column back to 0. 125 - s.Write([]byte("\rWorld" )) 126 - 127 - assertCell(t, s, 0, 0, 'W' ) 128 - assertCell(t, s, 1, 0, 'o' ) 129 - assertCell(t, s, 2, 0, 'r' ) 130 - assertCell(t, s, 3, 0, 'l' ) 131 - assertCell(t, s, 4, 0, 'd' ) 132 - if s.Col != 5 { 133 - t.Errorf("col after CR + World = %d, want 5" , s.Col) 134 - } 135 - } 136 - 137 - // --------------------------------------------------------------------------- 138 - // Line Feed 139 - // --------------------------------------------------------------------------- 140 - 141 - func TestLF(t *testing.T) {142 - s := NewScreen(5, 3) 143 - s.Write([]byte("A\r\nB\r\nC" )) 144 - 145 - assertCell(t, s, 0, 0, 'A' ) 146 - assertCell(t, s, 0, 1, 'B' ) 147 - assertCell(t, s, 0, 2, 'C' ) 148 - if s.Row != 2 || s.Col != 1 { 149 - t.Errorf("cursor at row=%d col=%d, want row=2 col=1" , s.Row, s.Col) 150 - } 151 - } 152 - 153 - func TestLFDoesNotResetColumn(t *testing.T) {154 - s := NewScreen(5, 3) 155 - s.Write([]byte("AB\nC" )) 156 - 157 - assertCell(t, s, 0, 0, 'A' ) 158 - assertCell(t, s, 1, 0, 'B' ) 159 - assertCell(t, s, 0, 1, ' ' ) 160 - assertCell(t, s, 2, 1, 'C' ) 161 - if s.Row != 1 || s.Col != 3 { 162 - t.Errorf("cursor at row=%d col=%d, want row=1 col=3" , s.Row, s.Col) 55 + func TestWritePrintableAndUTF8(t *testing.T) {56 + tests := []struct { 57 + name string 58 + run func (t *testing.T) 59 + }{ 60 + { 61 + name: "printable ASCII advances cursor and records damage" , 62 + run: func (t *testing.T) { 63 + s := NewScreen(5, 3) 64 + s.Write([]byte("Hi" )) 65 + 66 + assertCell(t, s, 0, 0, 'H' ) 67 + assertCell(t, s, 1, 0, 'i' ) 68 + if s.Col != 2 || s.Row != 0 { 69 + t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 70 + } 71 + 72 + // Should have damage. 73 + d := s.Damage() 74 + if len(d) == 0 { 75 + t.Fatal("expected damage after writing" ) 76 + } 77 + }, 78 + }, 79 + { 80 + name: "UTF-8 multi-byte runes occupy one cell each" , 81 + run: func (t *testing.T) { 82 + s := NewScreen(10, 3) 83 + s.Write([]byte("aäéø©" )) 84 + 85 + assertCell(t, s, 0, 0, 'a' ) 86 + assertCell(t, s, 1, 0, 'ä' ) 87 + assertCell(t, s, 2, 0, 'é' ) 88 + assertCell(t, s, 3, 0, 'ø' ) 89 + assertCell(t, s, 4, 0, '©' ) 90 + }, 91 + }, 92 + { 93 + name: "printable beyond width wraps to next line" , 94 + run: func (t *testing.T) { 95 + s := NewScreen(4, 2) 96 + s.Write([]byte("ABCDE" )) 97 + 98 + assertCell(t, s, 0, 0, 'A' ) 99 + assertCell(t, s, 1, 0, 'B' ) 100 + assertCell(t, s, 2, 0, 'C' ) 101 + assertCell(t, s, 3, 0, 'D' ) 102 + // E wrapped to next line. 103 + assertCell(t, s, 0, 1, 'E' ) 104 + }, 105 + }, 106 + { 107 + name: "printable beyond screen height scrolls" , 108 + run: func (t *testing.T) { 109 + s := NewScreen(3, 2) 110 + // Fill both lines. 111 + s.Write([]byte("ABC" )) 112 + s.Write([]byte("DEF" )) 113 + // Row is now 1 (bottom). One more char should scroll. 114 + s.Write([]byte("G" )) 115 + 116 + // After scroll: old row 0 = "DEF", new row 1 = "G ". 117 + assertCell(t, s, 0, 0, 'D' ) 118 + assertCell(t, s, 1, 0, 'E' ) 119 + assertCell(t, s, 2, 0, 'F' ) 120 + assertCell(t, s, 0, 1, 'G' ) 121 + assertCell(t, s, 1, 1, ' ' ) 122 + 123 + // Check scroll damage. 124 + d := s.Damage() 125 + if !hasDamageKind(d, renderer.DamageScrollUp) { 126 + t.Error("expected scroll damage" ) 127 + } 128 + }, 129 + }, 163 130 } 164 - } 165 131 166 - func TestLFAtPendingWrapBoundaryAdvancesOneLine(t *testing.T) {167 - s := NewScreen(5, 4) 168 - s.Write([]byte("ABCDE\nZ" )) 169 - 170 - assertCell(t, s, 4, 0, 'E' ) 171 - assertCell(t, s, 4, 1, 'Z' ) 172 - if s.Row != 1 || s.Col != 5 { 173 - t.Errorf("cursor at row=%d col=%d, want row=1 col=5" , s.Row, s.Col) 132 + for _, tt := range tests { 133 + t.Run(tt.name, tt.run) 174 134 } 175 135 } 176 136 177 - func TestLFScroll(t *testing.T) {178 - s := NewScreen(5, 2) 179 - // Fill both lines then CRLF to scroll and return to column 0. 180 - s.Write([]byte("AAAAA" )) 181 - s.Write([]byte("BBBBB" )) 182 - s.Write([]byte("\r\nCCCCC" )) 183 - 184 - assertCell(t, s, 0, 0, 'B' ) 185 - assertCell(t, s, 4, 1, 'C' ) 186 - } 187 - 188 137 // --------------------------------------------------------------------------- 189 - // Backspace 138 + // Carriage Return / Line Feed / Backspace / Tab 190 139 // --------------------------------------------------------------------------- 191 140 192 - func TestBS(t *testing.T) {193 - s := NewScreen(10, 3) 194 - s.Write([]byte("Hello\b" )) 195 - 196 - if s.Col != 4 { 197 - t.Errorf("col after BS = %d, want 4" , s.Col) 198 - } 199 - assertCell(t, s, 4, 0, 'o' ) // cell unchanged, just cursor moved 200 - } 201 - 202 - func TestBSAtColumnZero(t *testing.T) {203 - s := NewScreen(10, 3) 204 - s.Write([]byte("\b" )) // BS at col 0 should be no-op 205 - 206 - if s.Col != 0 { 207 - t.Errorf("col = %d, want 0" , s.Col) 141 + func TestCursorControlChars(t *testing.T) {142 + tests := []struct { 143 + name string 144 + run func (t *testing.T) 145 + }{ 146 + { 147 + name: "CR moves column back to 0" , 148 + run: func (t *testing.T) { 149 + s := NewScreen(10, 3) 150 + s.Write([]byte("Hello" )) 151 + // CR moves column back to 0. 152 + s.Write([]byte("\rWorld" )) 153 + 154 + assertCell(t, s, 0, 0, 'W' ) 155 + assertCell(t, s, 1, 0, 'o' ) 156 + assertCell(t, s, 2, 0, 'r' ) 157 + assertCell(t, s, 3, 0, 'l' ) 158 + assertCell(t, s, 4, 0, 'd' ) 159 + if s.Col != 5 { 160 + t.Errorf("col after CR + World = %d, want 5" , s.Col) 161 + } 162 + }, 163 + }, 164 + { 165 + name: "LF with CR moves to next line column 0" , 166 + run: func (t *testing.T) { 167 + s := NewScreen(5, 3) 168 + s.Write([]byte("A\r\nB\r\nC" )) 169 + 170 + assertCell(t, s, 0, 0, 'A' ) 171 + assertCell(t, s, 0, 1, 'B' ) 172 + assertCell(t, s, 0, 2, 'C' ) 173 + if s.Row != 2 || s.Col != 1 { 174 + t.Errorf("cursor at row=%d col=%d, want row=2 col=1" , s.Row, s.Col) 175 + } 176 + }, 177 + }, 178 + { 179 + name: "LF alone does not reset column" , 180 + run: func (t *testing.T) { 181 + s := NewScreen(5, 3) 182 + s.Write([]byte("AB\nC" )) 183 + 184 + assertCell(t, s, 0, 0, 'A' ) 185 + assertCell(t, s, 1, 0, 'B' ) 186 + assertCell(t, s, 0, 1, ' ' ) 187 + assertCell(t, s, 2, 1, 'C' ) 188 + if s.Row != 1 || s.Col != 3 { 189 + t.Errorf("cursor at row=%d col=%d, want row=1 col=3" , s.Row, s.Col) 190 + } 191 + }, 192 + }, 193 + { 194 + name: "LF at pending wrap boundary advances exactly one line" , 195 + run: func (t *testing.T) { 196 + s := NewScreen(5, 4) 197 + s.Write([]byte("ABCDE\nZ" )) 198 + 199 + assertCell(t, s, 4, 0, 'E' ) 200 + assertCell(t, s, 4, 1, 'Z' ) 201 + if s.Row != 1 || s.Col != 5 { 202 + t.Errorf("cursor at row=%d col=%d, want row=1 col=5" , s.Row, s.Col) 203 + } 204 + }, 205 + }, 206 + { 207 + name: "LF scrolls when both lines are full" , 208 + run: func (t *testing.T) { 209 + s := NewScreen(5, 2) 210 + // Fill both lines then CRLF to scroll and return to column 0. 211 + s.Write([]byte("AAAAA" )) 212 + s.Write([]byte("BBBBB" )) 213 + s.Write([]byte("\r\nCCCCC" )) 214 + 215 + assertCell(t, s, 0, 0, 'B' ) 216 + assertCell(t, s, 4, 1, 'C' ) 217 + }, 218 + }, 219 + { 220 + name: "BS moves cursor back without touching the cell" , 221 + run: func (t *testing.T) { 222 + s := NewScreen(10, 3) 223 + s.Write([]byte("Hello\b" )) 224 + 225 + if s.Col != 4 { 226 + t.Errorf("col after BS = %d, want 4" , s.Col) 227 + } 228 + assertCell(t, s, 4, 0, 'o' ) // cell unchanged, just cursor moved 229 + }, 230 + }, 231 + { 232 + name: "BS at column zero is a no-op" , 233 + run: func (t *testing.T) { 234 + s := NewScreen(10, 3) 235 + s.Write([]byte("\b" )) // BS at col 0 should be no-op 236 + 237 + if s.Col != 0 { 238 + t.Errorf("col = %d, want 0" , s.Col) 239 + } 240 + }, 241 + }, 242 + { 243 + name: "tab advances to next 8-column boundary" , 244 + run: func (t *testing.T) { 245 + s := NewScreen(20, 3) 246 + s.Write([]byte("A\tB" )) 247 + 248 + assertCell(t, s, 0, 0, 'A' ) 249 + // Tab advances to next 8-column boundary (col 8). 250 + for i := 1; i < 8; i++ { 251 + assertCell(t, s, i, 0, ' ' ) 252 + } 253 + assertCell(t, s, 8, 0, 'B' ) 254 + }, 255 + }, 208 256 } 209 - } 210 - 211 - // --------------------------------------------------------------------------- 212 - // Tab 213 - // --------------------------------------------------------------------------- 214 257 215 - func TestTab(t *testing.T) {216 - s := NewScreen(20, 3) 217 - s.Write([]byte("A\tB" )) 218 - 219 - assertCell(t, s, 0, 0, 'A' ) 220 - // Tab advances to next 8-column boundary (col 8). 221 - for i := 1; i < 8; i++ { 222 - assertCell(t, s, i, 0, ' ' ) 258 + for _, tt := range tests { 259 + t.Run(tt.name, tt.run) 223 260 } 224 - assertCell(t, s, 8, 0, 'B' ) 225 261 } 226 262 227 263 // --------------------------------------------------------------------------- 228 264 // CSI SGR 229 265 // --------------------------------------------------------------------------- 230 266 231 - func TestSGRDefault(t *testing.T) {232 - s := NewScreen(10, 3) 233 - s.Write([]byte("\x1b[0m" )) 234 - 235 - if s.Style != (renderer.DefaultStyle()) { 236 - t.Errorf("style after SGR 0 = %+v, want default" , s.Style) 237 - } 238 - } 239 - 240 - func TestSGRBold(t *testing.T) {241 - s := NewScreen(10, 3) 242 - s.Write([]byte("\x1b[1m" )) 243 - 244 - if !s.Style.Bold { 245 - t.Error("expected bold" ) 246 - } 247 - } 248 - 249 - func TestSGRInverse(t *testing.T) {250 - s := NewScreen(10, 3) 251 - s.Write([]byte("\x1b[7m" )) 252 - 253 - if !s.Style.Inverse { 254 - t.Error("expected inverse" ) 255 - } 256 - } 257 - 258 - func TestSGRForeground(t *testing.T) {259 - s := NewScreen(10, 3) 260 - s.Write([]byte("\x1b[31m" )) // red 261 - 262 - if s.Style.Foreground != 1 { 263 - t.Errorf("foreground = %d, want 1 (red)" , s.Style.Foreground) 264 - } 265 - } 266 - 267 - func TestSGRBackground(t *testing.T) {268 - s := NewScreen(10, 3) 269 - s.Write([]byte("\x1b[44m" )) // blue background 270 - 271 - if s.Style.Background != 4 { 272 - t.Errorf("background = %d, want 4 (blue)" , s.Style.Background) 273 - } 274 - } 275 - 276 - func TestSGR256Color(t *testing.T) {277 - s := NewScreen(10, 3) 278 - s.Write([]byte("\x1b[38;5;82m" )) // 256-color foreground 279 - 280 - if s.Style.Foreground != 82 { 281 - t.Errorf("foreground = %d, want 82" , s.Style.Foreground) 282 - } 283 - } 284 - 285 - func TestSGR256Background(t *testing.T) {286 - s := NewScreen(10, 3) 287 - s.Write([]byte("\x1b[48;5;200m" )) // 256-color background 288 - 289 - if s.Style.Background != 200 { 290 - t.Errorf("background = %d, want 200" , s.Style.Background) 291 - } 292 - } 293 - 294 - func TestSGRBrightForeground(t *testing.T) {295 - s := NewScreen(10, 3) 296 - s.Write([]byte("\x1b[91m" )) // bright red 297 - 298 - if s.Style.Foreground != 9 { 299 - t.Errorf("foreground = %d, want 9 (bright red)" , s.Style.Foreground) 300 - } 301 - } 302 - 303 - func TestSGRBrightBackground(t *testing.T) {304 - s := NewScreen(10, 3) 305 - s.Write([]byte("\x1b[107m" )) // bright white background 306 - 307 - if s.Style.Background != 15 { 308 - t.Errorf("background = %d, want 15 (bright white)" , s.Style.Background) 309 - } 310 - } 311 - 312 - func TestSGRTruecolorForeground(t *testing.T) {313 - s := NewScreen(10, 3) 314 - s.Write([]byte("\x1b[38;2;12;34;56mX" )) 315 - 316 - want := renderer.RGB{R: 12, G: 34, B: 56} 317 - if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { 318 - t.Errorf("foreground RGB = (%v, %+v), want true/%+v" , s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) 319 - } 320 - if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { 321 - t.Errorf("cell foreground RGB = %+v, want %+v" , got, want) 267 + func TestSGR(t *testing.T) {268 + tests := []struct { 269 + name string 270 + seq string 271 + check func (t *testing.T, s *Screen) 272 + }{ 273 + { 274 + name: "SGR 0 resets to default style" , 275 + seq: "\x1b[0m" , 276 + check: func (t *testing.T, s *Screen) { 277 + if s.Style != (renderer.DefaultStyle()) { 278 + t.Errorf("style after SGR 0 = %+v, want default" , s.Style) 279 + } 280 + }, 281 + }, 282 + { 283 + name: "SGR 1 sets bold" , 284 + seq: "\x1b[1m" , 285 + check: func (t *testing.T, s *Screen) { 286 + if !s.Style.Bold { 287 + t.Error("expected bold" ) 288 + } 289 + }, 290 + }, 291 + { 292 + name: "SGR 7 sets inverse" , 293 + seq: "\x1b[7m" , 294 + check: func (t *testing.T, s *Screen) { 295 + if !s.Style.Inverse { 296 + t.Error("expected inverse" ) 297 + } 298 + }, 299 + }, 300 + { 301 + name: "SGR 31 sets red foreground" , 302 + seq: "\x1b[31m" , 303 + check: func (t *testing.T, s *Screen) { 304 + if s.Style.Foreground != 1 { 305 + t.Errorf("foreground = %d, want 1 (red)" , s.Style.Foreground) 306 + } 307 + }, 308 + }, 309 + { 310 + name: "SGR 44 sets blue background" , 311 + seq: "\x1b[44m" , 312 + check: func (t *testing.T, s *Screen) { 313 + if s.Style.Background != 4 { 314 + t.Errorf("background = %d, want 4 (blue)" , s.Style.Background) 315 + } 316 + }, 317 + }, 318 + { 319 + name: "SGR 38;5 sets 256-color foreground" , 320 + seq: "\x1b[38;5;82m" , 321 + check: func (t *testing.T, s *Screen) { 322 + if s.Style.Foreground != 82 { 323 + t.Errorf("foreground = %d, want 82" , s.Style.Foreground) 324 + } 325 + }, 326 + }, 327 + { 328 + name: "SGR 48;5 sets 256-color background" , 329 + seq: "\x1b[48;5;200m" , 330 + check: func (t *testing.T, s *Screen) { 331 + if s.Style.Background != 200 { 332 + t.Errorf("background = %d, want 200" , s.Style.Background) 333 + } 334 + }, 335 + }, 336 + { 337 + name: "SGR 91 sets bright red foreground" , 338 + seq: "\x1b[91m" , 339 + check: func (t *testing.T, s *Screen) { 340 + if s.Style.Foreground != 9 { 341 + t.Errorf("foreground = %d, want 9 (bright red)" , s.Style.Foreground) 342 + } 343 + }, 344 + }, 345 + { 346 + name: "SGR 107 sets bright white background" , 347 + seq: "\x1b[107m" , 348 + check: func (t *testing.T, s *Screen) { 349 + if s.Style.Background != 15 { 350 + t.Errorf("background = %d, want 15 (bright white)" , s.Style.Background) 351 + } 352 + }, 353 + }, 354 + { 355 + name: "SGR 22 resets bold" , 356 + seq: "\x1b[1mHello\x1b[22mWorld" , 357 + check: func (t *testing.T, s *Screen) { 358 + if s.Style.Bold { 359 + t.Error("bold should be reset after 22" ) 360 + } 361 + }, 362 + }, 363 + { 364 + name: "SGR 27 resets inverse" , 365 + seq: "\x1b[7m\x1b[27m" , 366 + check: func (t *testing.T, s *Screen) { 367 + if s.Style.Inverse { 368 + t.Error("inverse should be reset after 27" ) 369 + } 370 + }, 371 + }, 372 + { 373 + name: "SGR 39 resets foreground to default" , 374 + seq: "\x1b[31m\x1b[39m" , 375 + check: func (t *testing.T, s *Screen) { 376 + if s.Style.Foreground != -1 { 377 + t.Errorf("foreground should be default after 39, got %d" , s.Style.Foreground) 378 + } 379 + }, 380 + }, 381 + { 382 + name: "SGR 49 resets background to default" , 383 + seq: "\x1b[44m\x1b[49m" , 384 + check: func (t *testing.T, s *Screen) { 385 + if s.Style.Background != -1 { 386 + t.Errorf("background should be default after 49, got %d" , s.Style.Background) 387 + } 388 + }, 389 + }, 390 + { 391 + name: "SGR style is applied to written cells" , 392 + seq: "\x1b[1;31mX" , 393 + check: func (t *testing.T, s *Screen) { 394 + c := cellAt(s, 0, 0) 395 + if !c.Style.Bold { 396 + t.Error("cell should be bold" ) 397 + } 398 + if c.Style.Foreground != 1 { 399 + t.Errorf("cell foreground = %d, want 1" , c.Style.Foreground) 400 + } 401 + }, 402 + }, 403 + { 404 + name: "multiple sequential SGR sequences accumulate" , 405 + seq: "\x1b[31m\x1b[1m\x1b[44mX" , 406 + check: func (t *testing.T, s *Screen) { 407 + c := cellAt(s, 0, 0) 408 + if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 { 409 + t.Errorf("cell style = %+v, want fg=1 bold bg=4" , c.Style) 410 + } 411 + }, 412 + }, 413 + { 414 + name: "empty SGR params reset style" , 415 + seq: "\x1b[31m\x1b[1m\x1b[m" , // empty params → reset 416 + check: func (t *testing.T, s *Screen) { 417 + if s.Style != (renderer.DefaultStyle()) { 418 + t.Errorf("style after empty SGR = %+v, want default" , s.Style) 419 + } 420 + }, 421 + }, 322 422 } 323 - } 324 - 325 - func TestSGRTruecolorBackground(t *testing.T) {326 - s := NewScreen(10, 3) 327 - s.Write([]byte("\x1b[48;2;200;100;50m" )) 328 423 329 - want := renderer.RGB{R: 200, G: 100, B: 50} 330 - if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { 331 - t.Errorf("background RGB = (%v, %+v), want true/%+v" , s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) 424 + for _, tt := range tests { 425 + t.Run(tt.name, func (t *testing.T) { 426 + s := NewScreen(10, 3) 427 + s.Write([]byte(tt.seq)) 428 + tt.check(t, s) 429 + }) 332 430 } 333 431 } 334 432 335 - func TestSGRDefaultForegroundClearsTruecolor(t *testing.T) {336 - s := NewScreen(10, 3) 337 - s.Write([]byte("\x1b[38;2;12;34;56m\x1b[39m" )) 338 - 339 - if s.Style.HasForegroundRGB || s.Style.Foreground != -1 { 340 - t.Errorf("foreground after reset = rgb:%v index:%d, want default" , s.Style.HasForegroundRGB, s.Style.Foreground) 433 + func TestSGRTruecolor(t *testing.T) {434 + tests := []struct { 435 + name string 436 + seq string 437 + check func (t *testing.T, s *Screen) 438 + }{ 439 + { 440 + name: "SGR 38;2 sets truecolor foreground on style and cell" , 441 + seq: "\x1b[38;2;12;34;56mX" , 442 + check: func (t *testing.T, s *Screen) { 443 + want := renderer.RGB{R: 12, G: 34, B: 56} 444 + if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { 445 + t.Errorf("foreground RGB = (%v, %+v), want true/%+v" , s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) 446 + } 447 + if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { 448 + t.Errorf("cell foreground RGB = %+v, want %+v" , got, want) 449 + } 450 + }, 451 + }, 452 + { 453 + name: "SGR 48;2 sets truecolor background" , 454 + seq: "\x1b[48;2;200;100;50m" , 455 + check: func (t *testing.T, s *Screen) { 456 + want := renderer.RGB{R: 200, G: 100, B: 50} 457 + if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { 458 + t.Errorf("background RGB = (%v, %+v), want true/%+v" , s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) 459 + } 460 + }, 461 + }, 462 + { 463 + name: "SGR 39 after truecolor clears truecolor foreground" , 464 + seq: "\x1b[38;2;12;34;56m\x1b[39m" , 465 + check: func (t *testing.T, s *Screen) { 466 + if s.Style.HasForegroundRGB || s.Style.Foreground != -1 { 467 + t.Errorf("foreground after reset = rgb:%v index:%d, want default" , s.Style.HasForegroundRGB, s.Style.Foreground) 468 + } 469 + }, 470 + }, 471 + { 472 + name: "truecolor components are clamped to 0-255" , 473 + seq: "\x1b[38;2;-1;300;42m" , 474 + check: func (t *testing.T, s *Screen) { 475 + want := renderer.RGB{R: 0, G: 255, B: 42} 476 + if s.Style.ForegroundRGB != want { 477 + t.Errorf("foreground RGB = %+v, want %+v" , s.Style.ForegroundRGB, want) 478 + } 479 + }, 480 + }, 341 481 } 342 - } 343 - 344 - func TestSGRTruecolorClampsComponents(t *testing.T) {345 - s := NewScreen(10, 3) 346 - s.Write([]byte("\x1b[38;2;-1;300;42m" )) 347 482 348 - want := renderer.RGB{R: 0, G: 255, B: 42} 349 - if s.Style.ForegroundRGB != want { 350 - t.Errorf("foreground RGB = %+v, want %+v" , s.Style.ForegroundRGB, want) 483 + for _, tt := range tests { 484 + t.Run(tt.name, func (t *testing.T) { 485 + s := NewScreen(10, 3) 486 + s.Write([]byte(tt.seq)) 487 + tt.check(t, s) 488 + }) 351 489 } 352 490 } 353 491 ⋯ 30 hidden lines@@ -384,317 +522,390 @@ func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) { 384 522 } 385 523 } 386 524 387 - func TestSGRResetBold(t *testing.T) {388 - s := NewScreen(10, 3) 389 - s.Write([]byte("\x1b[1mHello\x1b[22mWorld" )) 390 - 391 - if s.Style.Bold { 392 - t.Error("bold should be reset after 22" ) 393 - } 394 - } 395 - 396 - func TestSGRResetInverse(t *testing.T) {397 - s := NewScreen(10, 3) 398 - s.Write([]byte("\x1b[7m\x1b[27m" )) 399 - 400 - if s.Style.Inverse { 401 - t.Error("inverse should be reset after 27" ) 402 - } 403 - } 404 - 405 - func TestSGRDefaultForeground(t *testing.T) {406 - s := NewScreen(10, 3) 407 - s.Write([]byte("\x1b[31m\x1b[39m" )) 408 - 409 - if s.Style.Foreground != -1 { 410 - t.Errorf("foreground should be default after 39, got %d" , s.Style.Foreground) 411 - } 412 - } 413 - 414 - func TestSGRDefaultBackground(t *testing.T) {415 - s := NewScreen(10, 3) 416 - s.Write([]byte("\x1b[44m\x1b[49m" )) 525 + // --------------------------------------------------------------------------- 526 + // Clear screen / clear line / erase modes 527 + // --------------------------------------------------------------------------- 417 528 418 - if s.Style.Background != -1 { 419 - t.Errorf("background should be default after 49, got %d" , s.Style.Background) 529 + func TestClearAndErase(t *testing.T) {530 + tests := []struct { 531 + name string 532 + run func (t *testing.T) 533 + }{ 534 + { 535 + name: "CSI 2J clears the whole screen" , 536 + run: func (t *testing.T) { 537 + s := NewScreen(5, 3) 538 + s.Write([]byte("HelloWorldABCDE" )) 539 + // Clear damage from writing. 540 + s.ClearDamage() 541 + 542 + s.Write([]byte("\x1b[2J" )) 543 + 544 + // All cells should be blank. 545 + for y := 0; y < 3; y++ { 546 + for x := 0; x < 5; x++ { 547 + if c := cellAt(s, x, y); c.Rune != ' ' { 548 + t.Errorf("cell(%d,%d) = %q, want space after clear" , x, y, c.Rune) 549 + } 550 + } 551 + } 552 + 553 + d := s.Damage() 554 + if !hasDamageKind(d, renderer.DamageClear) { 555 + t.Error("expected DamageClear after CSI 2 J" ) 556 + } 557 + }, 558 + }, 559 + { 560 + name: "CSI K clears from cursor to end of line" , 561 + run: func (t *testing.T) { 562 + s := NewScreen(10, 3) 563 + s.Write([]byte("HelloWorld" )) 564 + s.ClearDamage() 565 + 566 + // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end. 567 + s.Write([]byte("\x1b[1;6H" )) 568 + s.Write([]byte("\x1b[K" )) 569 + 570 + for x := 5; x < 10; x++ { 571 + if c := cellAt(s, x, 0); c.Rune != ' ' { 572 + t.Errorf("cell(%d,0) = %q, want space after clear line" , x, c.Rune) 573 + } 574 + } 575 + // First 5 chars should remain. 576 + assertCell(t, s, 0, 0, 'H' ) 577 + 578 + d := s.Damage() 579 + if !hasDamageKind(d, renderer.DamageClear) { 580 + t.Error("expected DamageClear after CSI K" ) 581 + } 582 + }, 583 + }, 584 + { 585 + name: "erase display modes 0/1/2 (implicit, 1, 2/3)" , 586 + run: func (t *testing.T) { 587 + s := NewScreen(5, 3) 588 + s.Write([]byte("abcdefghijklmno" )) 589 + s.Write([]byte("\x1b[2;3H" )) 590 + s.ClearDamage() 591 + s.Write([]byte("\x1b[J" )) 592 + assertCell(t, s, 0, 0, 'a' ) 593 + assertCell(t, s, 1, 1, 'g' ) 594 + if c := cellAt(s, 2, 1); c.Rune != ' ' { 595 + t.Fatalf("cursor-to-end clear left %q at cursor" , c.Rune) 596 + } 597 + 598 + s = NewScreen(5, 3) 599 + s.Write([]byte("abcdefghijklmno" )) 600 + s.Write([]byte("\x1b[2;3H" )) 601 + s.Write([]byte("\x1b[1J" )) 602 + if c := cellAt(s, 0, 0); c.Rune != ' ' { 603 + t.Fatalf("start-to-cursor clear left %q at start" , c.Rune) 604 + } 605 + assertCell(t, s, 3, 1, 'i' ) 606 + }, 607 + }, 608 + { 609 + name: "erase line modes 0/1/2" , 610 + run: func (t *testing.T) { 611 + s := NewScreen(5, 2) 612 + s.Write([]byte("abcde" )) 613 + s.Write([]byte("\x1b[1;3H" )) 614 + s.Write([]byte("\x1b[1K" )) 615 + if c := cellAt(s, 0, 0); c.Rune != ' ' { 616 + t.Fatalf("line start clear left %q" , c.Rune) 617 + } 618 + assertCell(t, s, 3, 0, 'd' ) 619 + 620 + s = NewScreen(5, 2) 621 + s.Write([]byte("abcde" )) 622 + s.Write([]byte("\x1b[1;3H" )) 623 + s.Write([]byte("\x1b[2K" )) 624 + for x := 0; x < 5; x++ { 625 + if c := cellAt(s, x, 0); c.Rune != ' ' { 626 + t.Fatalf("line clear left %q at %d" , c.Rune, x) 627 + } 628 + } 629 + }, 630 + }, 420 631 } 421 - } 422 632 423 - func TestSGRAppliesToCells(t *testing.T) {424 - s := NewScreen(10, 3) 425 - s.Write([]byte("\x1b[1;31mX" )) 426 - 427 - c := cellAt(s, 0, 0) 428 - if !c.Style.Bold { 429 - t.Error("cell should be bold" ) 430 - } 431 - if c.Style.Foreground != 1 { 432 - t.Errorf("cell foreground = %d, want 1" , c.Style.Foreground) 633 + for _, tt := range tests { 634 + t.Run(tt.name, tt.run) 433 635 } 434 636 } 435 637 436 638 // --------------------------------------------------------------------------- 437 - // Clear screen 639 + // Cursor move 438 640 // --------------------------------------------------------------------------- 439 641 440 - func TestClearScreen(t *testing.T) {441 - s := NewScreen(5, 3) 442 - s.Write([]byte("HelloWorldABCDE" )) 443 - // Clear damage from writing. 444 - s.ClearDamage() 642 + func TestCursorMove(t *testing.T) {643 + tests := []struct { 644 + name string 645 + seq string 646 + initRow int 647 + initCol int 648 + wantRow int 649 + wantCol int 650 + }{ 651 + {name: "CSI H moves to explicit row/col" , seq: "\x1b[3;4H" , wantRow: 2, wantCol: 3}, 652 + {name: "CSI H clamps to bottom-right when out of bounds" , seq: "\x1b[100;200H" , wantRow: 4, wantCol: 9}, 653 + {name: "CSI f moves to explicit row/col" , seq: "\x1b[2;8f" , wantRow: 1, wantCol: 7}, 654 + {name: "CSI C moves forward after CR" , seq: "\r\x1b[3C" , wantRow: 0, wantCol: 3}, 655 + {name: "CSI H with no params moves to (1,1)" , seq: "\x1b[H" , wantRow: 0, wantCol: 0}, 656 + {name: "CSI ;5H uses default row with explicit column" , seq: "\x1b[;5H" , initRow: 3, initCol: 4, wantRow: 0, wantCol: 4}, 657 + } 445 658 446 - s.Write([]byte("\x1b[2J" )) 659 + for _, tt := range tests { 660 + t.Run(tt.name, func (t *testing.T) { 661 + s := NewScreen(10, 5) 662 + s.Row, s.Col = tt.initRow, tt.initCol 663 + s.Write([]byte(tt.seq)) 447 664 448 - // All cells should be blank. 449 - for y := 0; y < 3; y++ { 450 - for x := 0; x < 5; x++ { 451 - if c := cellAt(s, x, y); c.Rune != ' ' { 452 - t.Errorf("cell(%d,%d) = %q, want space after clear" , x, y, c.Rune) 665 + if s.Row != tt.wantRow || s.Col != tt.wantCol { 666 + t.Errorf("cursor at row=%d col=%d, want row=%d col=%d" , s.Row, s.Col, tt.wantRow, tt.wantCol) 453 667 } 454 - } 455 - } 456 - 457 - d := s.Damage() 458 - if !hasDamageKind(d, renderer.DamageClear) { 459 - t.Error("expected DamageClear after CSI 2 J" ) 668 + }) 460 669 } 461 670 } 462 671 463 672 // --------------------------------------------------------------------------- 464 - // Clear line 673 + // ESC sequences 465 674 // --------------------------------------------------------------------------- 466 675 467 - func TestClearLine(t *testing.T) {468 - s := NewScreen(10, 3) 469 - s.Write([]byte("HelloWorld" )) 470 - s.ClearDamage() 471 - 472 - // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end. 473 - s.Write([]byte("\x1b[1;6H" )) 474 - s.Write([]byte("\x1b[K" )) 475 - 476 - for x := 5; x < 10; x++ { 477 - if c := cellAt(s, x, 0); c.Rune != ' ' { 478 - t.Errorf("cell(%d,0) = %q, want space after clear line" , x, c.Rune) 479 - } 676 + func TestESCSequences(t *testing.T) {677 + tests := []struct { 678 + name string 679 + run func (t *testing.T) 680 + }{ 681 + { 682 + name: "ESC = (DECKPAM) is ignored, following text is printable" , 683 + run: func (t *testing.T) { 684 + s := NewScreen(10, 2) 685 + s.Write([]byte("\x1b=abc" )) 686 + 687 + assertCell(t, s, 0, 0, 'a' ) 688 + assertCell(t, s, 1, 0, 'b' ) 689 + assertCell(t, s, 2, 0, 'c' ) 690 + if s.Col != 3 || s.Row != 0 { 691 + t.Errorf("cursor at row=%d col=%d, want row=0 col=3" , s.Row, s.Col) 692 + } 693 + }, 694 + }, 695 + { 696 + name: "ESC 7 / ESC 8 save and restore the cursor" , 697 + run: func (t *testing.T) { 698 + s := NewScreen(10, 3) 699 + s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X" )) 700 + 701 + assertCell(t, s, 0, 0, 'a' ) 702 + assertCell(t, s, 1, 0, 'b' ) 703 + assertCell(t, s, 2, 0, 'c' ) 704 + assertCell(t, s, 3, 0, 'X' ) 705 + assertCell(t, s, 4, 1, 'Z' ) 706 + assertCell(t, s, 5, 1, 'Z' ) 707 + if s.Row != 0 || s.Col != 4 { 708 + t.Errorf("cursor at row=%d col=%d, want row=0 col=4" , s.Row, s.Col) 709 + } 710 + }, 711 + }, 712 + { 713 + name: "ESC D (index), ESC E (next line), ESC M (reverse index)" , 714 + run: func (t *testing.T) { 715 + s := NewScreen(5, 3) 716 + s.Write([]byte("A\x1bDB\x1bEC" )) 717 + 718 + assertCell(t, s, 0, 0, 'A' ) 719 + assertCell(t, s, 1, 1, 'B' ) 720 + assertCell(t, s, 0, 2, 'C' ) 721 + 722 + s = NewScreen(5, 3) 723 + s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop" )) 724 + assertCell(t, s, 0, 0, 't' ) 725 + assertCell(t, s, 0, 1, 'm' ) 726 + }, 727 + }, 480 728 } 481 - // First 5 chars should remain. 482 - assertCell(t, s, 0, 0, 'H' ) 483 729 484 - d := s.Damage() 485 - if !hasDamageKind(d, renderer.DamageClear) { 486 - t.Error("expected DamageClear after CSI K" ) 730 + for _, tt := range tests { 731 + t.Run(tt.name, tt.run) 487 732 } 488 733 } 489 734 490 735 // --------------------------------------------------------------------------- 491 - // Cursor move 736 + // CSI editing sequences (cursor moves, ICH/DCH, IL/DL, scroll region, alt screen) 492 737 // --------------------------------------------------------------------------- 493 738 494 - func TestCursorMoveH(t *testing.T) {495 - s := NewScreen(10, 5) 496 - s.Write([]byte("\x1b[3;4H" )) 497 - 498 - if s.Row != 2 || s.Col != 3 { 499 - t.Errorf("cursor at row=%d col=%d, want row=2 col=3" , s.Row, s.Col) 500 - } 501 - } 502 - 503 - func TestCursorMoveOutsideBounds(t *testing.T) {504 - s := NewScreen(10, 5) 505 - s.Write([]byte("\x1b[100;200H" )) 506 - 507 - // Should clamp to bottom-right. 508 - if s.Row != 4 || s.Col != 9 { 509 - t.Errorf("cursor at row=%d col=%d, want row=4 col=9" , s.Row, s.Col) 510 - } 511 - } 512 - 513 - func TestCursorMoveF(t *testing.T) {514 - s := NewScreen(10, 5) 515 - s.Write([]byte("\x1b[2;8f" )) 516 - 517 - if s.Row != 1 || s.Col != 7 { 518 - t.Errorf("cursor at row=%d col=%d, want row=1 col=7" , s.Row, s.Col) 519 - } 520 - } 521 - 522 - func TestCursorMoveForwardC(t *testing.T) {523 - s := NewScreen(10, 5) 524 - s.Write([]byte("\r\x1b[3C" )) 525 - 526 - if s.Row != 0 || s.Col != 3 { 527 - t.Errorf("cursor at row=%d col=%d, want row=0 col=3" , s.Row, s.Col) 528 - } 529 - } 530 - 531 - func TestESC_DECKPAMIgnored(t *testing.T) {532 - s := NewScreen(10, 2) 533 - s.Write([]byte("\x1b=abc" )) 534 - 535 - assertCell(t, s, 0, 0, 'a' ) 536 - assertCell(t, s, 1, 0, 'b' ) 537 - assertCell(t, s, 2, 0, 'c' ) 538 - if s.Col != 3 || s.Row != 0 { 539 - t.Errorf("cursor at row=%d col=%d, want row=0 col=3" , s.Row, s.Col) 540 - } 541 - } 542 - 543 - func TestESC_SaveRestoreCursor(t *testing.T) {544 - s := NewScreen(10, 3) 545 - s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X" )) 546 - 547 - assertCell(t, s, 0, 0, 'a' ) 548 - assertCell(t, s, 1, 0, 'b' ) 549 - assertCell(t, s, 2, 0, 'c' ) 550 - assertCell(t, s, 3, 0, 'X' ) 551 - assertCell(t, s, 4, 1, 'Z' ) 552 - assertCell(t, s, 5, 1, 'Z' ) 553 - if s.Row != 0 || s.Col != 4 { 554 - t.Errorf("cursor at row=%d col=%d, want row=0 col=4" , s.Row, s.Col) 555 - } 556 - } 557 - 558 - func TestESC_IndexNextLineAndReverseIndex(t *testing.T) {559 - s := NewScreen(5, 3) 560 - s.Write([]byte("A\x1bDB\x1bEC" )) 561 - 562 - assertCell(t, s, 0, 0, 'A' ) 563 - assertCell(t, s, 1, 1, 'B' ) 564 - assertCell(t, s, 0, 2, 'C' ) 565 - 566 - s = NewScreen(5, 3) 567 - s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop" )) 568 - assertCell(t, s, 0, 0, 't' ) 569 - assertCell(t, s, 0, 1, 'm' ) 570 - } 571 - 572 - func TestCSI_CursorDirectionalMoves(t *testing.T) {573 - s := NewScreen(10, 4) 574 - s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>" )) 575 - 576 - assertCell(t, s, 4, 2, 'X' ) 577 - assertCell(t, s, 3, 2, '<' ) 578 - assertCell(t, s, 4, 1, '^' ) 579 - assertCell(t, s, 5, 2, 'v' ) 580 - assertCell(t, s, 9, 2, '>' ) 581 - } 582 - 583 - func TestCSI_InsertDeleteCharacters(t *testing.T) {584 - s := NewScreen(8, 2) 585 - s.Write([]byte("abcdef" )) 586 - s.Write([]byte("\x1b[1;3H\x1b[2@XY" )) 587 - if got := lineText(s, 0); got != "abXYcdef" { 588 - t.Fatalf("line after ICH = %q, want %q" , got, "abXYcdef" ) 589 - } 590 - 591 - s.Write([]byte("\x1b[1;4H\x1b[3P" )) 592 - if got := lineText(s, 0); got != "abXef " { 593 - t.Fatalf("line after DCH = %q, want %q" , got, "abXef " ) 594 - } 595 - } 596 - 597 - func TestCSI_InsertDeleteLines(t *testing.T) {598 - s := NewScreen(5, 4) 599 - s.Write([]byte("11111222223333344444" )) 600 - s.Write([]byte("\x1b[2;1H\x1b[L" )) 601 - if got := lineText(s, 1); got != " " { 602 - t.Fatalf("inserted line = %q, want blank" , got) 603 - } 604 - if got := lineText(s, 2); got != "22222" { 605 - t.Fatalf("shifted line = %q, want 22222" , got) 606 - } 607 - 608 - s.Write([]byte("\x1b[2;1H\x1b[M" )) 609 - if got := lineText(s, 1); got != "22222" { 610 - t.Fatalf("line after delete = %q, want 22222" , got) 611 - } 612 - } 613 - 614 - func TestCSI_ScrollRegion(t *testing.T) {615 - s := NewScreen(5, 4) 616 - s.Write([]byte("11111222223333344444" )) 617 - s.Write([]byte("\x1b[2;3r\x1b[3;1H\n" )) 618 - 619 - if got := lineText(s, 0); got != "11111" { 620 - t.Fatalf("row outside region changed: %q" , got) 621 - } 622 - if got := lineText(s, 1); got != "33333" { 623 - t.Fatalf("region did not scroll up, row 1 = %q" , got) 624 - } 625 - if got := lineText(s, 2); got != " " { 626 - t.Fatalf("region bottom not blanked, row 2 = %q" , got) 627 - } 628 - if got := lineText(s, 3); got != "44444" { 629 - t.Fatalf("row below region changed: %q" , got) 739 + func TestCSIEditingSequences(t *testing.T) {740 + tests := []struct { 741 + name string 742 + run func (t *testing.T) 743 + }{ 744 + { 745 + name: "directional cursor moves (A/B/C/D)" , 746 + run: func (t *testing.T) { 747 + s := NewScreen(10, 4) 748 + s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>" )) 749 + 750 + assertCell(t, s, 4, 2, 'X' ) 751 + assertCell(t, s, 3, 2, '<' ) 752 + assertCell(t, s, 4, 1, '^' ) 753 + assertCell(t, s, 5, 2, 'v' ) 754 + assertCell(t, s, 9, 2, '>' ) 755 + }, 756 + }, 757 + { 758 + name: "insert and delete characters (@ and P)" , 759 + run: func (t *testing.T) { 760 + s := NewScreen(8, 2) 761 + s.Write([]byte("abcdef" )) 762 + s.Write([]byte("\x1b[1;3H\x1b[2@XY" )) 763 + if got := lineText(s, 0); got != "abXYcdef" { 764 + t.Fatalf("line after ICH = %q, want %q" , got, "abXYcdef" ) 765 + } 766 + 767 + s.Write([]byte("\x1b[1;4H\x1b[3P" )) 768 + if got := lineText(s, 0); got != "abXef " { 769 + t.Fatalf("line after DCH = %q, want %q" , got, "abXef " ) 770 + } 771 + }, 772 + }, 773 + { 774 + name: "insert and delete lines (L and M)" , 775 + run: func (t *testing.T) { 776 + s := NewScreen(5, 4) 777 + s.Write([]byte("11111222223333344444" )) 778 + s.Write([]byte("\x1b[2;1H\x1b[L" )) 779 + if got := lineText(s, 1); got != " " { 780 + t.Fatalf("inserted line = %q, want blank" , got) 781 + } 782 + if got := lineText(s, 2); got != "22222" { 783 + t.Fatalf("shifted line = %q, want 22222" , got) 784 + } 785 + 786 + s.Write([]byte("\x1b[2;1H\x1b[M" )) 787 + if got := lineText(s, 1); got != "22222" { 788 + t.Fatalf("line after delete = %q, want 22222" , got) 789 + } 790 + }, 791 + }, 792 + { 793 + name: "scroll region (CSI r) confines LF scrolling" , 794 + run: func (t *testing.T) { 795 + s := NewScreen(5, 4) 796 + s.Write([]byte("11111222223333344444" )) 797 + s.Write([]byte("\x1b[2;3r\x1b[3;1H\n" )) 798 + 799 + if got := lineText(s, 0); got != "11111" { 800 + t.Fatalf("row outside region changed: %q" , got) 801 + } 802 + if got := lineText(s, 1); got != "33333" { 803 + t.Fatalf("region did not scroll up, row 1 = %q" , got) 804 + } 805 + if got := lineText(s, 2); got != " " { 806 + t.Fatalf("region bottom not blanked, row 2 = %q" , got) 807 + } 808 + if got := lineText(s, 3); got != "44444" { 809 + t.Fatalf("row below region changed: %q" , got) 810 + } 811 + }, 812 + }, 813 + { 814 + name: "private alternate screen mode restores normal screen on exit" , 815 + run: func (t *testing.T) { 816 + s := NewScreen(20, 5) 817 + s.Write([]byte("normal-line" )) 818 + s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP" )) 819 + s.Write([]byte("\x1b[?1049lafter" )) 820 + 821 + if got := lineText(s, 0); got != "normal-lineafter " { 822 + t.Fatalf("row 0 = %q, want restored normal screen with post-exit text" , got) 823 + } 824 + if got := lineText(s, 2); got != " " { 825 + t.Fatalf("alternate content leaked into row 2: %q" , got) 826 + } 827 + }, 828 + }, 630 829 } 631 - } 632 - 633 - func TestCSI_PrivateAlternateScreenRestoresNormalScreen(t *testing.T) {634 - s := NewScreen(20, 5) 635 - s.Write([]byte("normal-line" )) 636 - s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP" )) 637 - s.Write([]byte("\x1b[?1049lafter" )) 638 830 639 - if got := lineText(s, 0); got != "normal-lineafter " { 640 - t.Fatalf("row 0 = %q, want restored normal screen with post-exit text" , got) 641 - } 642 - if got := lineText(s, 2); got != " " { 643 - t.Fatalf("alternate content leaked into row 2: %q" , got) 831 + for _, tt := range tests { 832 + t.Run(tt.name, tt.run) 644 833 } 645 834 } 646 835 647 - func TestRendererStaysInSyncAfterVTEditDamage(t *testing.T) {648 - s := NewScreen(8, 2) 649 - r := renderer.New(renderer.Capabilities{}) 650 - s.Write([]byte("abcdef" )) 651 - if _, err := r.Draw(s.Frame, s.Damage()); err != nil { 652 - t.Fatal(err) 653 - } 654 - s.ClearDamage() 655 - 656 - s.Write([]byte("\x1b[1;3H\x1b[2@XY" )) 657 - if _, err := r.Draw(s.Frame, s.Damage()); err != nil { 658 - t.Fatal(err) 659 - } 660 - s.ClearDamage() 661 - 662 - out, err := r.Draw(s.Frame, nil ) 663 - if err != nil { 664 - t.Fatal(err) 665 - } 666 - if len(out) != 0 { 667 - t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q" , string(out)) 668 - } 669 - } 836 + // --------------------------------------------------------------------------- 837 + // Integration scenarios 838 + // --------------------------------------------------------------------------- 670 839 671 - func TestDCS_STTerminatedIgnored(t *testing.T) {672 - s := NewScreen(20, 2) 673 - s.Write([]byte("\x1bP+q696e646e\x1b\\prompt" )) 674 - 675 - assertCell(t, s, 0, 0, 'p' ) 676 - assertCell(t, s, 1, 0, 'r' ) 677 - assertCell(t, s, 2, 0, 'o' ) 678 - assertCell(t, s, 3, 0, 'm' ) 679 - assertCell(t, s, 4, 0, 'p' ) 680 - assertCell(t, s, 5, 0, 't' ) 681 - if s.Col != 6 || s.Row != 0 { 682 - t.Errorf("cursor at row=%d col=%d, want row=0 col=6" , s.Row, s.Col) 840 + func TestIntegrationScenarios(t *testing.T) {841 + tests := []struct { 842 + name string 843 + run func (t *testing.T) 844 + }{ 845 + { 846 + name: "renderer stays in sync after VT edit damage" , 847 + run: func (t *testing.T) { 848 + s := NewScreen(8, 2) 849 + r := renderer.New(renderer.Capabilities{}) 850 + s.Write([]byte("abcdef" )) 851 + if _, err := r.Draw(s.Frame, s.Damage()); err != nil { 852 + t.Fatal(err) 853 + } 854 + s.ClearDamage() 855 + 856 + s.Write([]byte("\x1b[1;3H\x1b[2@XY" )) 857 + if _, err := r.Draw(s.Frame, s.Damage()); err != nil { 858 + t.Fatal(err) 859 + } 860 + s.ClearDamage() 861 + 862 + out, err := r.Draw(s.Frame, nil ) 863 + if err != nil { 864 + t.Fatal(err) 865 + } 866 + if len(out) != 0 { 867 + t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q" , string(out)) 868 + } 869 + }, 870 + }, 871 + { 872 + name: "DCS sequence terminated by ST is ignored" , 873 + run: func (t *testing.T) { 874 + s := NewScreen(20, 2) 875 + s.Write([]byte("\x1bP+q696e646e\x1b\\prompt" )) 876 + 877 + assertCell(t, s, 0, 0, 'p' ) 878 + assertCell(t, s, 1, 0, 'r' ) 879 + assertCell(t, s, 2, 0, 'o' ) 880 + assertCell(t, s, 3, 0, 'm' ) 881 + assertCell(t, s, 4, 0, 'p' ) 882 + assertCell(t, s, 5, 0, 't' ) 883 + if s.Col != 6 || s.Row != 0 { 884 + t.Errorf("cursor at row=%d col=%d, want row=0 col=6" , s.Row, s.Col) 885 + } 886 + }, 887 + }, 888 + { 889 + name: "fish-like prompt redraw preserves typed characters" , 890 + run: func (t *testing.T) { 891 + s := NewScreen(10, 2) 892 + s.Write([]byte("> " )) 893 + s.Write([]byte("a\r\x1b[3C" )) 894 + s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C" )) 895 + 896 + assertCell(t, s, 0, 0, '>' ) 897 + assertCell(t, s, 1, 0, ' ' ) 898 + assertCell(t, s, 2, 0, 'a' ) 899 + assertCell(t, s, 3, 0, 'b' ) 900 + if s.Row != 0 || s.Col != 4 { 901 + t.Errorf("cursor at row=%d col=%d, want row=0 col=4" , s.Row, s.Col) 902 + } 903 + }, 904 + }, 683 905 } 684 - } 685 906 686 - func TestFishLikePromptRedrawPreservesTypedCharacters(t *testing.T) {687 - s := NewScreen(10, 2) 688 - s.Write([]byte("> " )) 689 - s.Write([]byte("a\r\x1b[3C" )) 690 - s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C" )) 691 - 692 - assertCell(t, s, 0, 0, '>' ) 693 - assertCell(t, s, 1, 0, ' ' ) 694 - assertCell(t, s, 2, 0, 'a' ) 695 - assertCell(t, s, 3, 0, 'b' ) 696 - if s.Row != 0 || s.Col != 4 { 697 - t.Errorf("cursor at row=%d col=%d, want row=0 col=4" , s.Row, s.Col) 907 + for _, tt := range tests { 908 + t.Run(tt.name, tt.run) 698 909 } 699 910 } 700 911 ⋯ 2 hidden lines@@ -853,338 +1114,371 @@ func TestResizeSameSize(t *testing.T) { 853 1114 // --------------------------------------------------------------------------- 854 1115 855 1116 func TestClearDamage(t *testing.T) {856 - s := NewScreen(5, 3) 857 - s.Write([]byte("Hello" )) 858 - s.ClearDamage() 859 - d := s.Damage() 860 - if len(d) != 0 { 861 - t.Fatalf("expected empty damage after ClearDamage, got %+v" , d) 862 - } 863 - } 864 - 865 - // --------------------------------------------------------------------------- 866 - // Non-printable / invalid bytes 867 - // --------------------------------------------------------------------------- 868 - 869 - func TestInvalidBytes(t *testing.T) {870 - s := NewScreen(5, 3) 871 - // Invalid UTF-8 continuation byte without a start byte. 872 - s.Write([]byte{0x80, 0x81}) 873 - // Should not panic, and no cells should be modified. 874 - for y := 0; y < 3; y++ { 875 - for x := 0; x < 5; x++ { 876 - if c := cellAt(s, x, y); c.Rune != ' ' { 877 - t.Errorf("cell(%d,%d) = %q, want space" , x, y, c.Rune) 878 - } 879 - } 1117 + tests := []struct { 1118 + name string 1119 + }{ 1120 + {name: "ClearDamage empties the damage list" }, 880 1121 } 881 - } 882 1122 883 - func TestControlChars(t *testing.T) {884 - s := NewScreen(10, 3) 885 - // Control characters below 0x20 that are not handled (CR/LF/BS/TAB) should be ignored. 886 - ctrl := []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f} 887 - s.Write(ctrl) 888 - // No cells should be modified. 889 - for y := 0; y < 3; y++ { 890 - for x := 0; x < 10; x++ { 891 - if c := cellAt(s, x, y); c.Rune != ' ' { 892 - t.Errorf("cell(%d,%d) = %q, want space after control chars" , x, y, c.Rune) 1123 + for _, tt := range tests { 1124 + t.Run(tt.name, func (t *testing.T) { 1125 + s := NewScreen(5, 3) 1126 + s.Write([]byte("Hello" )) 1127 + s.ClearDamage() 1128 + d := s.Damage() 1129 + if len(d) != 0 { 1130 + t.Fatalf("expected empty damage after ClearDamage, got %+v" , d) 893 1131 } 894 - } 1132 + }) 895 1133 } 896 1134 } 897 1135 898 1136 // --------------------------------------------------------------------------- 899 - // Multiple sequential CSI sequences 1137 + // Non-printable / invalid bytes 900 1138 // --------------------------------------------------------------------------- 901 1139 902 - func TestMultipleSGR(t *testing.T) {903 - s := NewScreen(10, 3) 904 - s.Write([]byte("\x1b[31m\x1b[1m\x1b[44mX" )) 905 - 906 - c := cellAt(s, 0, 0) 907 - if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 { 908 - t.Errorf("cell style = %+v, want fg=1 bold bg=4" , c.Style) 1140 + func TestInvalidInputIgnored(t *testing.T) {1141 + tests := []struct { 1142 + name string 1143 + input []byte 1144 + }{ 1145 + { 1146 + name: "invalid UTF-8 continuation byte without start byte" , 1147 + input: []byte{0x80, 0x81}, 1148 + }, 1149 + { 1150 + name: "unhandled control characters below 0x20" , 1151 + input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f}, 1152 + }, 909 1153 } 910 - } 911 - 912 - func TestSGREmptyParamsReset(t *testing.T) {913 - s := NewScreen(10, 3) 914 - s.Write([]byte("\x1b[31m\x1b[1m\x1b[m" )) // empty params → reset 915 1154 916 - if s.Style != (renderer.DefaultStyle()) { 917 - t.Errorf("style after empty SGR = %+v, want default" , s.Style) 1155 + for _, tt := range tests { 1156 + t.Run(tt.name, func (t *testing.T) { 1157 + s := NewScreen(10, 3) 1158 + s.Write(tt.input) 1159 + // Should not panic, and no cells should be modified. 1160 + for y := 0; y < 3; y++ { 1161 + for x := 0; x < 10; x++ { 1162 + if c := cellAt(s, x, y); c.Rune != ' ' { 1163 + t.Errorf("cell(%d,%d) = %q, want space" , x, y, c.Rune) 1164 + } 1165 + } 1166 + } 1167 + }) 918 1168 } 919 1169 } 920 1170 921 1171 // --------------------------------------------------------------------------- 922 - // Cursor move with zero / missing params 1172 + // No overflow on edge writes 923 1173 // --------------------------------------------------------------------------- 924 1174 925 - func TestCursorMoveZeroParams(t *testing.T) {926 - s := NewScreen(10, 5) 927 - s.Write([]byte("\x1b[H" )) // no params → (1,1) 928 - 929 - if s.Row != 0 || s.Col != 0 { 930 - t.Errorf("cursor at (%d,%d), want (0,0)" , s.Row, s.Col) 1175 + func TestWriteAtEdgeNoPanic(t *testing.T) {1176 + tests := []struct { 1177 + name string 1178 + }{ 1179 + {name: "writing past a 1x1 screen scrolls instead of panicking" }, 931 1180 } 932 - } 933 - 934 - func TestCursorMovePartialParams(t *testing.T) {935 - s := NewScreen(10, 5) 936 - s.Row, s.Col = 3, 4 937 - s.Write([]byte("\x1b[;5H" )) // row=1 (default), col=5 938 1181 939 - if s.Row != 0 || s.Col != 4 { 940 - t.Errorf("cursor at (%d,%d), want (0,4)" , s.Row, s.Col) 1182 + for _, tt := range tests { 1183 + t.Run(tt.name, func (t *testing.T) { 1184 + s := NewScreen(1, 1) 1185 + // Fill the only cell then try to write more (should scroll). 1186 + s.Write([]byte("ABC" )) 1187 + // Should not panic. 1188 + _ = cellAt(s, 0, 0) 1189 + }) 941 1190 } 942 1191 } 943 1192 944 - // --------------------------------------------------------------------------- 945 - // No overflow on edge writes 946 - // --------------------------------------------------------------------------- 947 - 948 - func TestWriteAtEdgeNoPanic(t *testing.T) {949 - s := NewScreen(1, 1) 950 - // Fill the only cell then try to write more (should scroll). 951 - s.Write([]byte("ABC" )) 952 - // Should not panic. 953 - _ = cellAt(s, 0, 0) 954 - } 955 - 956 1193 // --------------------------------------------------------------------------- 957 1194 // Style persistence across writes 958 1195 // --------------------------------------------------------------------------- 959 1196 960 1197 func TestStylePersists(t *testing.T) {961 - s := NewScreen(10, 3) 962 - s.Write([]byte("\x1b[1mAB" )) 963 - s.Write([]byte("CD" )) 964 - 965 - c1 := cellAt(s, 0, 0) 966 - c2 := cellAt(s, 2, 0) 967 - if !c1.Style.Bold || !c2.Style.Bold { 968 - t.Error("bold should persist across Write calls" ) 969 - } 970 - } 971 - 972 - // --------------------------------------------------------------------------- 973 - // Rune width (CJK, emoji, combining marks, zero-width) 974 - // --------------------------------------------------------------------------- 975 - 976 - func TestCJKWideChars(t *testing.T) {977 - s := NewScreen(10, 3) 978 - // Write two CJK characters that are width 2 in a real terminal. 979 - // They should be rendered as '?' placeholders, advancing cursor by 1 each. 980 - s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd" )) // 你好 981 - 982 - assertCell(t, s, 0, 0, '?' ) 983 - assertCell(t, s, 1, 0, '?' ) 984 - if s.Col != 2 || s.Row != 0 { 985 - t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 986 - } 987 - } 988 - 989 - func TestCJKMixedWithASCII(t *testing.T) {990 - s := NewScreen(10, 3) 991 - s.Write([]byte("A\xe4\xbd\xa0B" )) // A你B 992 - 993 - assertCell(t, s, 0, 0, 'A' ) 994 - assertCell(t, s, 1, 0, '?' ) 995 - assertCell(t, s, 2, 0, 'B' ) 996 - if s.Col != 3 { 997 - t.Errorf("cursor at col=%d, want 3" , s.Col) 1198 + tests := []struct { 1199 + name string 1200 + }{ 1201 + {name: "bold persists across separate Write calls" }, 998 1202 } 999 - } 1000 1203 1001 - func TestEmojiRenderedAsPlaceholder(t *testing.T) {1002 - s := NewScreen(10, 3) 1003 - em := []byte("\U0001f600" ) // 😀 grinning face 1004 - s.Write(em) 1204 + for _, tt := range tests { 1205 + t.Run(tt.name, func (t *testing.T) { 1206 + s := NewScreen(10, 3) 1207 + s.Write([]byte("\x1b[1mAB" )) 1208 + s.Write([]byte("CD" )) 1005 1209 1006 - // Wide emoji is rendered as '?' placeholder. 1007 - assertCell(t, s, 0, 0, '?' ) 1008 - if s.Col != 1 { 1009 - t.Errorf("cursor at col=%d, want 1" , s.Col) 1210 + c1 := cellAt(s, 0, 0) 1211 + c2 := cellAt(s, 2, 0) 1212 + if !c1.Style.Bold || !c2.Style.Bold { 1213 + t.Error("bold should persist across Write calls" ) 1214 + } 1215 + }) 1010 1216 } 1011 1217 } 1012 1218 1013 - func TestCombiningMarkSkipped(t *testing.T) {1014 - s := NewScreen(10, 3) 1015 - // 'A' followed by combining acute accent U+0301 1016 - s.Write([]byte("A\xcc\x81" )) 1219 + // --------------------------------------------------------------------------- 1220 + // Rune width (CJK, emoji, combining marks, zero-width) 1221 + // --------------------------------------------------------------------------- 1017 1222 1018 - assertCell(t, s, 0, 0, 'A' ) 1019 - // Combining mark should be skipped — no cell written, no cursor advance. 1020 - if s.Col != 1 { 1021 - t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)" , s.Col) 1223 + func TestWideAndZeroWidthRunes(t *testing.T) {1224 + tests := []struct { 1225 + name string 1226 + run func (t *testing.T) 1227 + }{ 1228 + { 1229 + name: "CJK wide characters render as single-cell placeholders" , 1230 + run: func (t *testing.T) { 1231 + s := NewScreen(10, 3) 1232 + // Write two CJK characters that are width 2 in a real terminal. 1233 + // They should be rendered as '?' placeholders, advancing cursor by 1 each. 1234 + s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd" )) // 你好 1235 + 1236 + assertCell(t, s, 0, 0, '?' ) 1237 + assertCell(t, s, 1, 0, '?' ) 1238 + if s.Col != 2 || s.Row != 0 { 1239 + t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 1240 + } 1241 + }, 1242 + }, 1243 + { 1244 + name: "CJK mixed with ASCII" , 1245 + run: func (t *testing.T) { 1246 + s := NewScreen(10, 3) 1247 + s.Write([]byte("A\xe4\xbd\xa0B" )) // A你B 1248 + 1249 + assertCell(t, s, 0, 0, 'A' ) 1250 + assertCell(t, s, 1, 0, '?' ) 1251 + assertCell(t, s, 2, 0, 'B' ) 1252 + if s.Col != 3 { 1253 + t.Errorf("cursor at col=%d, want 3" , s.Col) 1254 + } 1255 + }, 1256 + }, 1257 + { 1258 + name: "emoji rendered as placeholder" , 1259 + run: func (t *testing.T) { 1260 + s := NewScreen(10, 3) 1261 + em := []byte("\U0001f600" ) // 😀 grinning face 1262 + s.Write(em) 1263 + 1264 + // Wide emoji is rendered as '?' placeholder. 1265 + assertCell(t, s, 0, 0, '?' ) 1266 + if s.Col != 1 { 1267 + t.Errorf("cursor at col=%d, want 1" , s.Col) 1268 + } 1269 + }, 1270 + }, 1271 + { 1272 + name: "combining mark is skipped without advancing cursor" , 1273 + run: func (t *testing.T) { 1274 + s := NewScreen(10, 3) 1275 + // 'A' followed by combining acute accent U+0301 1276 + s.Write([]byte("A\xcc\x81" )) 1277 + 1278 + assertCell(t, s, 0, 0, 'A' ) 1279 + // Combining mark should be skipped — no cell written, no cursor advance. 1280 + if s.Col != 1 { 1281 + t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)" , s.Col) 1282 + } 1283 + }, 1284 + }, 1285 + { 1286 + name: "zero-width characters are skipped without advancing cursor" , 1287 + run: func (t *testing.T) { 1288 + s := NewScreen(10, 3) 1289 + // Zero-width space U+200B 1290 + s.Write([]byte("A\xe2\x80\x8bB" )) 1291 + 1292 + assertCell(t, s, 0, 0, 'A' ) 1293 + assertCell(t, s, 1, 0, 'B' ) 1294 + if s.Col != 2 { 1295 + t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)" , s.Col) 1296 + } 1297 + }, 1298 + }, 1299 + { 1300 + name: "CJK surrounded by ASCII on both sides" , 1301 + run: func (t *testing.T) { 1302 + s := NewScreen(10, 3) 1303 + s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc" )) // a你b好c 1304 + 1305 + assertCell(t, s, 0, 0, 'a' ) 1306 + assertCell(t, s, 1, 0, '?' ) 1307 + assertCell(t, s, 2, 0, 'b' ) 1308 + assertCell(t, s, 3, 0, '?' ) 1309 + assertCell(t, s, 4, 0, 'c' ) 1310 + if s.Col != 5 { 1311 + t.Errorf("cursor at col=%d, want 5" , s.Col) 1312 + } 1313 + }, 1314 + }, 1315 + { 1316 + name: "CJK at the edge of the line wraps via scroll on a 1-row screen" , 1317 + run: func (t *testing.T) { 1318 + s := NewScreen(2, 1) 1319 + // Write two ASCII chars to fill the line. 1320 + s.Write([]byte("AB" )) // Cells = [A B], Col=2 1321 + // CJK at col 2 (edge) triggers newline → scroll on 1-row screen. 1322 + // scrollUp blanks the only row, then '?' is written at col 0. 1323 + s.Write([]byte("\xe4\xbd\xa0" )) // 你 1324 + 1325 + assertCell(t, s, 0, 0, '?' ) // CJK placeholder after scroll+blank 1326 + assertCell(t, s, 1, 0, ' ' ) // untouched 1327 + if s.Col != 1 || s.Row != 0 { 1328 + t.Errorf("cursor at col=%d row=%d, want col=1 row=0" , s.Col, s.Row) 1329 + } 1330 + }, 1331 + }, 1332 + { 1333 + name: "CJK damage width is normalized to 1" , 1334 + run: func (t *testing.T) { 1335 + s := NewScreen(10, 3) 1336 + s.ClearDamage() 1337 + s.Write([]byte("\xe4\xbd\xa0" )) // 你 1338 + 1339 + d := s.Damage() 1340 + if len(d) == 0 { 1341 + t.Fatal("expected damage" ) 1342 + } 1343 + // After normalization, damage width should be 1, not 2. 1344 + if d[0].Width != 1 { 1345 + t.Errorf("damage width = %d, want 1 (normalized)" , d[0].Width) 1346 + } 1347 + }, 1348 + }, 1349 + { 1350 + name: "emoji damage width is normalized to 1" , 1351 + run: func (t *testing.T) { 1352 + s := NewScreen(10, 3) 1353 + s.ClearDamage() 1354 + s.Write([]byte("\U0001f600" )) // 😀 1355 + 1356 + d := s.Damage() 1357 + if len(d) == 0 { 1358 + t.Fatal("expected damage" ) 1359 + } 1360 + if d[0].Width != 1 { 1361 + t.Errorf("damage width = %d, want 1 (normalized)" , d[0].Width) 1362 + } 1363 + }, 1364 + }, 1365 + { 1366 + name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned" , 1367 + run: func (t *testing.T) { 1368 + s := NewScreen(10, 3) 1369 + // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́' 1370 + s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81" )) 1371 + 1372 + assertCell(t, s, 0, 0, 'a' ) // 'a' 1373 + assertCell(t, s, 1, 0, '?' ) // 你 normalized 1374 + assertCell(t, s, 2, 0, '?' ) // 😀 normalized 1375 + assertCell(t, s, 3, 0, 'b' ) // 'b' 1376 + // Combining mark skipped, no cell at col 4. 1377 + // Cursor at col 4 (advanced by a, CJK, emoji, b). 1378 + if s.Col != 4 || s.Row != 0 { 1379 + t.Errorf("cursor at col=%d row=%d, want col=4 row=0" , s.Col, s.Row) 1380 + } 1381 + }, 1382 + }, 1383 + { 1384 + name: "combining mark alone writes no cell and produces no damage" , 1385 + run: func (t *testing.T) { 1386 + s := NewScreen(10, 3) 1387 + s.ClearDamage() 1388 + s.Write([]byte("\xcc\x81" )) // combining acute accent alone (no base char) 1389 + 1390 + // No cells should be modified. 1391 + for y := 0; y < 3; y++ { 1392 + for x := 0; x < 10; x++ { 1393 + if c := cellAt(s, x, y); c.Rune != ' ' { 1394 + t.Errorf("cell(%d,%d) = %q, want space" , x, y, c.Rune) 1395 + } 1396 + } 1397 + } 1398 + if s.Col != 0 || s.Row != 0 { 1399 + t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1400 + } 1401 + d := s.Damage() 1402 + if len(d) != 0 { 1403 + t.Errorf("expected no damage for combining mark alone, got %+v" , d) 1404 + } 1405 + }, 1406 + }, 1022 1407 } 1023 - } 1024 - 1025 - func TestZeroWidthCharsSkipped(t *testing.T) {1026 - s := NewScreen(10, 3) 1027 - // Zero-width space U+200B 1028 - s.Write([]byte("A\xe2\x80\x8bB" )) 1029 1408 1030 - assertCell(t, s, 0, 0, 'A' ) 1031 - assertCell(t, s, 1, 0, 'B' ) 1032 - if s.Col != 2 { 1033 - t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)" , s.Col) 1409 + for _, tt := range tests { 1410 + t.Run(tt.name, tt.run) 1034 1411 } 1035 1412 } 1036 1413 1037 1414 func TestRuneWidth(t *testing.T) {1038 1415 tests := []struct { 1416 + name string 1039 1417 r rune 1040 1418 width int 1041 1419 }{ 1042 - {' ' , 1}, 1043 - {'a' , 1}, 1044 - {'A' , 1}, 1045 - {'1' , 1}, 1046 - {0x00AD, 0}, // soft hyphen 1047 - {0x034F, 0}, // combining grapheme joiner 1048 - {0x0301, 0}, // combining acute accent 1049 - {0x0300, 0}, // combining grave accent 1050 - {0x200B, 0}, // zero-width space 1051 - {0x200C, 0}, // zero-width non-joiner 1052 - {0x200D, 0}, // zero-width joiner 1053 - {0xFEFF, 0}, // BOM / zero-width no-break space 1054 - {0x4E00, 2}, // CJK Unified Ideograph (一) 1055 - {0x4E8C, 2}, // CJK (二) 1056 - {0x9FFF, 2}, // CJK end of BMP 1057 - {0x3400, 2}, // CJK Extension A 1058 - {0xAC00, 2}, // Hangul Syllable (가) 1059 - {0xD7AF, 2}, // Hangul Syllable end 1060 - {0x3000, 2}, // CJK Ideographic Space 1061 - {0xFF01, 2}, // Fullwidth Exclamation Mark 1062 - {0xFFE0, 2}, // Fullwidth Cent Sign 1063 - {0x1F600, 2}, // 😀 1064 - {0x1F680, 2}, // 🚀 1065 - {'\x00' , 0}, 1066 - {'\x01' , 0}, 1067 - {'\x1b' , 0}, 1068 - {0x7F, 0}, 1420 + {"space" , ' ' , 1}, 1421 + {"lowercase ascii letter" , 'a' , 1}, 1422 + {"uppercase ascii letter" , 'A' , 1}, 1423 + {"ascii digit" , '1' , 1}, 1424 + {"soft hyphen" , 0x00AD, 0}, 1425 + {"combining grapheme joiner" , 0x034F, 0}, 1426 + {"combining acute accent" , 0x0301, 0}, 1427 + {"combining grave accent" , 0x0300, 0}, 1428 + {"zero-width space" , 0x200B, 0}, 1429 + {"zero-width non-joiner" , 0x200C, 0}, 1430 + {"zero-width joiner" , 0x200D, 0}, 1431 + {"BOM / zero-width no-break space" , 0xFEFF, 0}, 1432 + {"CJK Unified Ideograph (一)" , 0x4E00, 2}, 1433 + {"CJK (二)" , 0x4E8C, 2}, 1434 + {"CJK end of BMP" , 0x9FFF, 2}, 1435 + {"CJK Extension A" , 0x3400, 2}, 1436 + {"Hangul Syllable (가)" , 0xAC00, 2}, 1437 + {"Hangul Syllable end" , 0xD7AF, 2}, 1438 + {"CJK Ideographic Space" , 0x3000, 2}, 1439 + {"Fullwidth Exclamation Mark" , 0xFF01, 2}, 1440 + {"Fullwidth Cent Sign" , 0xFFE0, 2}, 1441 + {"grinning face emoji" , 0x1F600, 2}, 1442 + {"rocket emoji" , 0x1F680, 2}, 1443 + {"NUL" , '\x00' , 0}, 1444 + {"SOH" , '\x01' , 0}, 1445 + {"ESC" , '\x1b' , 0}, 1446 + {"DEL" , 0x7F, 0}, 1069 1447 } 1070 1448 1071 1449 for _, tt := range tests { 1072 - got := runeWidth(tt.r) 1073 - if got != tt.width { 1074 - t.Errorf("runeWidth(%U %q) = %d, want %d" , tt.r, tt.r, got, tt.width) 1075 - } 1450 + t.Run(tt.name, func (t *testing.T) { 1451 + got := runeWidth(tt.r) 1452 + if got != tt.width { 1453 + t.Errorf("runeWidth(%U %q) = %d, want %d" , tt.r, tt.r, got, tt.width) 1454 + } 1455 + }) 1076 1456 } 1077 1457 } 1078 1458 1079 1459 func TestDamageReturnsReference(t *testing.T) {1080 - // Damage() should return a reference to internal slice (no copy). 1081 - s := NewScreen(10, 3) 1082 - s.Write([]byte("X" )) 1083 - d1 := s.Damage() 1084 - // Internal slice should be the same (or at least reference the same backing). 1085 - if len(d1) == 0 { 1086 - t.Fatal("expected damage" ) 1087 - } 1088 - // Write more to trigger record. 1089 - s.Write([]byte("Y" )) 1090 - d2 := s.Damage() 1091 - _ = d2 1092 - // Both d1 and d2 reference the internal slice; we just don't crash. 1093 - } 1094 - 1095 - func TestCJKSurroundedByASCII(t *testing.T) {1096 - s := NewScreen(10, 3) 1097 - s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc" )) // a你b好c 1098 - 1099 - assertCell(t, s, 0, 0, 'a' ) 1100 - assertCell(t, s, 1, 0, '?' ) 1101 - assertCell(t, s, 2, 0, 'b' ) 1102 - assertCell(t, s, 3, 0, '?' ) 1103 - assertCell(t, s, 4, 0, 'c' ) 1104 - if s.Col != 5 { 1105 - t.Errorf("cursor at col=%d, want 5" , s.Col) 1106 - } 1107 - } 1108 - 1109 - func TestCJKAtEdgeWraps(t *testing.T) {1110 - s := NewScreen(2, 1) 1111 - // Write two ASCII chars to fill the line. 1112 - s.Write([]byte("AB" )) // Cells = [A B], Col=2 1113 - // CJK at col 2 (edge) triggers newline → scroll on 1-row screen. 1114 - // scrollUp blanks the only row, then '?' is written at col 0. 1115 - s.Write([]byte("\xe4\xbd\xa0" )) // 你 1116 - 1117 - assertCell(t, s, 0, 0, '?' ) // CJK placeholder after scroll+blank 1118 - assertCell(t, s, 1, 0, ' ' ) // untouched 1119 - if s.Col != 1 || s.Row != 0 { 1120 - t.Errorf("cursor at col=%d row=%d, want col=1 row=0" , s.Col, s.Row) 1121 - } 1122 - } 1123 - 1124 - func TestCJKDamageWidthNormalized(t *testing.T) {1125 - s := NewScreen(10, 3) 1126 - s.ClearDamage() 1127 - s.Write([]byte("\xe4\xbd\xa0" )) // 你 1128 - 1129 - d := s.Damage() 1130 - if len(d) == 0 { 1131 - t.Fatal("expected damage" ) 1132 - } 1133 - // After normalization, damage width should be 1, not 2. 1134 - if d[0].Width != 1 { 1135 - t.Errorf("damage width = %d, want 1 (normalized)" , d[0].Width) 1136 - } 1137 - } 1138 - 1139 - func TestEmojiDamageWidthNormalized(t *testing.T) {1140 - s := NewScreen(10, 3) 1141 - s.ClearDamage() 1142 - s.Write([]byte("\U0001f600" )) // 😀 1143 - 1144 - d := s.Damage() 1145 - if len(d) == 0 { 1146 - t.Fatal("expected damage" ) 1147 - } 1148 - if d[0].Width != 1 { 1149 - t.Errorf("damage width = %d, want 1 (normalized)" , d[0].Width) 1150 - } 1151 - } 1152 - 1153 - func TestMixedCursorAlignment(t *testing.T) {1154 - s := NewScreen(10, 3) 1155 - // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́' 1156 - s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81" )) 1157 - 1158 - assertCell(t, s, 0, 0, 'a' ) // 'a' 1159 - assertCell(t, s, 1, 0, '?' ) // 你 normalized 1160 - assertCell(t, s, 2, 0, '?' ) // 😀 normalized 1161 - assertCell(t, s, 3, 0, 'b' ) // 'b' 1162 - // Combining mark skipped, no cell at col 4. 1163 - // Cursor at col 4 (advanced by a, CJK, emoji, b). 1164 - if s.Col != 4 || s.Row != 0 { 1165 - t.Errorf("cursor at col=%d row=%d, want col=4 row=0" , s.Col, s.Row) 1460 + tests := []struct { 1461 + name string 1462 + }{ 1463 + {name: "Damage() returns a reference to the internal slice" }, 1166 1464 } 1167 - } 1168 1465 1169 - func TestCombiningMarkNoCellWritten(t *testing.T) {1170 - s := NewScreen(10, 3) 1171 - s.ClearDamage() 1172 - s.Write([]byte("\xcc\x81" )) // combining acute accent alone (no base char) 1173 - 1174 - // No cells should be modified. 1175 - for y := 0; y < 3; y++ { 1176 - for x := 0; x < 10; x++ { 1177 - if c := cellAt(s, x, y); c.Rune != ' ' { 1178 - t.Errorf("cell(%d,%d) = %q, want space" , x, y, c.Rune) 1466 + for _, tt := range tests { 1467 + t.Run(tt.name, func (t *testing.T) { 1468 + // Damage() should return a reference to internal slice (no copy). 1469 + s := NewScreen(10, 3) 1470 + s.Write([]byte("X" )) 1471 + d1 := s.Damage() 1472 + // Internal slice should be the same (or at least reference the same backing). 1473 + if len(d1) == 0 { 1474 + t.Fatal("expected damage" ) 1179 1475 } 1180 - } 1181 - } 1182 - if s.Col != 0 || s.Row != 0 { 1183 - t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1184 - } 1185 - d := s.Damage() 1186 - if len(d) != 0 { 1187 - t.Errorf("expected no damage for combining mark alone, got %+v" , d) 1476 + // Write more to trigger record. 1477 + s.Write([]byte("Y" )) 1478 + d2 := s.Damage() 1479 + _ = d2 1480 + // Both d1 and d2 reference the internal slice; we just don't crash. 1481 + }) 1188 1482 } 1189 1483 } 1190 1484 ⋯ 1 hidden line@@ -1192,124 +1486,148 @@ func TestCombiningMarkNoCellWritten(t *testing.T) { 1192 1486 // CSI S / SU — Scroll Up 1193 1487 // --------------------------------------------------------------------------- 1194 1488 1195 - func TestCSI_SCrollUpDefault(t *testing.T) {1196 - s := NewScreen(5, 4) 1197 - s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1198 - s.ClearDamage() 1199 - 1200 - // CSI S with no param → scroll up by 1. 1201 - s.Write([]byte("\x1b[S" )) 1202 - 1203 - // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank. 1204 - assertCell(t, s, 0, 0, 'B' ) 1205 - assertCell(t, s, 0, 1, 'C' ) 1206 - assertCell(t, s, 0, 2, 'D' ) 1207 - assertCell(t, s, 0, 3, ' ' ) 1208 - 1209 - // Cursor should NOT have moved. 1210 - if s.Col != 5 || s.Row != 3 { 1211 - t.Errorf("cursor at col=%d row=%d, want col=5 row=3" , s.Col, s.Row) 1212 - } 1213 - 1214 - d := s.Damage() 1215 - if !hasDamageKind(d, renderer.DamageScrollUp) { 1216 - t.Errorf("expected DamageScrollUp, got %v" , damageKinds(d)) 1217 - } 1218 - } 1219 - 1220 - func TestCSI_SCrollUpWithCount(t *testing.T) {1221 - s := NewScreen(5, 4) 1222 - s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1223 - s.ClearDamage() 1224 - 1225 - // CSI 2 S → scroll up by 2. 1226 - s.Write([]byte("\x1b[2S" )) 1227 - 1228 - // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank. 1229 - assertCell(t, s, 0, 0, 'C' ) 1230 - assertCell(t, s, 0, 1, 'D' ) 1231 - assertCell(t, s, 0, 2, ' ' ) 1232 - assertCell(t, s, 0, 3, ' ' ) 1233 - 1234 - // Cursor preserved. 1235 - if s.Col != 5 || s.Row != 3 { 1236 - t.Errorf("cursor at col=%d row=%d, want col=5 row=3" , s.Col, s.Row) 1237 - } 1238 - } 1239 - 1240 - func TestCSI_SCrollUpClamp(t *testing.T) {1241 - s := NewScreen(5, 3) 1242 - s.Write([]byte("AAAAABBBBBCCCCC" )) // fill 3 rows 1243 - s.ClearDamage() 1244 - 1245 - // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank). 1246 - s.Write([]byte("\x1b[100S" )) 1247 - 1248 - // All rows should be blank. 1249 - for y := 0; y < 3; y++ { 1250 - for x := 0; x < 5; x++ { 1251 - assertCell(t, s, x, y, ' ' ) 1252 - } 1253 - } 1254 - } 1255 - 1256 - func TestCSI_SCrollUpCountZero(t *testing.T) {1257 - s := NewScreen(5, 3) 1258 - s.Write([]byte("AAAAABBBBBCCCCC" )) // fill 3 rows 1259 - s.ClearDamage() 1260 - 1261 - // CSI 0 S → default to 1 (parameter 0 means default in VT spec). 1262 - s.Write([]byte("\x1b[0S" )) 1263 - 1264 - // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank. 1265 - assertCell(t, s, 0, 0, 'B' ) 1266 - assertCell(t, s, 0, 1, 'C' ) 1267 - assertCell(t, s, 0, 2, ' ' ) 1268 - } 1269 - 1270 - func TestCSI_SCrollUpDamageCount(t *testing.T) {1271 - s := NewScreen(5, 4) 1272 - s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1273 - s.ClearDamage() 1274 - 1275 - s.Write([]byte("\x1b[3S" )) // scroll up by 3 1276 - 1277 - d := s.Damage() 1278 - var scrollDamage *renderer.Damage 1279 - for i, dd := range d { 1280 - if dd.Kind == renderer.DamageScrollUp { 1281 - scrollDamage = &d[i] 1282 - break 1283 - } 1284 - } 1285 - if scrollDamage == nil { 1286 - t.Fatal("expected DamageScrollUp" ) 1287 - } 1288 - if scrollDamage.Count != 3 { 1289 - t.Errorf("scroll count = %d, want 3" , scrollDamage.Count) 1290 - } 1291 - if scrollDamage.Width != 5 || scrollDamage.Height != 4 { 1292 - t.Errorf("scroll size = %dx%d, want 5x4" , scrollDamage.Width, scrollDamage.Height) 1293 - } 1294 - 1295 - // Should also have text damage for the blanked rows (bottom 3 rows). 1296 - if !hasDamageKind(d, renderer.DamageText) { 1297 - t.Error("expected DamageText for blanked rows" ) 1489 + func TestCSIScrollUp(t *testing.T) {1490 + tests := []struct { 1491 + name string 1492 + run func (t *testing.T) 1493 + }{ 1494 + { 1495 + name: "CSI S with no param scrolls up by 1 and preserves cursor" , 1496 + run: func (t *testing.T) { 1497 + s := NewScreen(5, 4) 1498 + s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1499 + s.ClearDamage() 1500 + 1501 + // CSI S with no param → scroll up by 1. 1502 + s.Write([]byte("\x1b[S" )) 1503 + 1504 + // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank. 1505 + assertCell(t, s, 0, 0, 'B' ) 1506 + assertCell(t, s, 0, 1, 'C' ) 1507 + assertCell(t, s, 0, 2, 'D' ) 1508 + assertCell(t, s, 0, 3, ' ' ) 1509 + 1510 + // Cursor should NOT have moved. 1511 + if s.Col != 5 || s.Row != 3 { 1512 + t.Errorf("cursor at col=%d row=%d, want col=5 row=3" , s.Col, s.Row) 1513 + } 1514 + 1515 + d := s.Damage() 1516 + if !hasDamageKind(d, renderer.DamageScrollUp) { 1517 + t.Errorf("expected DamageScrollUp, got %v" , damageKinds(d)) 1518 + } 1519 + }, 1520 + }, 1521 + { 1522 + name: "CSI 2S scrolls up by an explicit count" , 1523 + run: func (t *testing.T) { 1524 + s := NewScreen(5, 4) 1525 + s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1526 + s.ClearDamage() 1527 + 1528 + // CSI 2 S → scroll up by 2. 1529 + s.Write([]byte("\x1b[2S" )) 1530 + 1531 + // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank. 1532 + assertCell(t, s, 0, 0, 'C' ) 1533 + assertCell(t, s, 0, 1, 'D' ) 1534 + assertCell(t, s, 0, 2, ' ' ) 1535 + assertCell(t, s, 0, 3, ' ' ) 1536 + 1537 + // Cursor preserved. 1538 + if s.Col != 5 || s.Row != 3 { 1539 + t.Errorf("cursor at col=%d row=%d, want col=5 row=3" , s.Col, s.Row) 1540 + } 1541 + }, 1542 + }, 1543 + { 1544 + name: "CSI 100S on a 3-row screen clamps to the full screen" , 1545 + run: func (t *testing.T) { 1546 + s := NewScreen(5, 3) 1547 + s.Write([]byte("AAAAABBBBBCCCCC" )) // fill 3 rows 1548 + s.ClearDamage() 1549 + 1550 + // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank). 1551 + s.Write([]byte("\x1b[100S" )) 1552 + 1553 + // All rows should be blank. 1554 + for y := 0; y < 3; y++ { 1555 + for x := 0; x < 5; x++ { 1556 + assertCell(t, s, x, y, ' ' ) 1557 + } 1558 + } 1559 + }, 1560 + }, 1561 + { 1562 + name: "CSI 0S defaults to a scroll count of 1" , 1563 + run: func (t *testing.T) { 1564 + s := NewScreen(5, 3) 1565 + s.Write([]byte("AAAAABBBBBCCCCC" )) // fill 3 rows 1566 + s.ClearDamage() 1567 + 1568 + // CSI 0 S → default to 1 (parameter 0 means default in VT spec). 1569 + s.Write([]byte("\x1b[0S" )) 1570 + 1571 + // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank. 1572 + assertCell(t, s, 0, 0, 'B' ) 1573 + assertCell(t, s, 0, 1, 'C' ) 1574 + assertCell(t, s, 0, 2, ' ' ) 1575 + }, 1576 + }, 1577 + { 1578 + name: "CSI 3S reports scroll count 3 and blanked-row text damage" , 1579 + run: func (t *testing.T) { 1580 + s := NewScreen(5, 4) 1581 + s.Write([]byte("AAAAABBBBBCCCCCDDDDD" )) // fill all 4 rows 1582 + s.ClearDamage() 1583 + 1584 + s.Write([]byte("\x1b[3S" )) // scroll up by 3 1585 + 1586 + d := s.Damage() 1587 + var scrollDamage *renderer.Damage 1588 + for i, dd := range d { 1589 + if dd.Kind == renderer.DamageScrollUp { 1590 + scrollDamage = &d[i] 1591 + break 1592 + } 1593 + } 1594 + if scrollDamage == nil { 1595 + t.Fatal("expected DamageScrollUp" ) 1596 + } 1597 + if scrollDamage.Count != 3 { 1598 + t.Errorf("scroll count = %d, want 3" , scrollDamage.Count) 1599 + } 1600 + if scrollDamage.Width != 5 || scrollDamage.Height != 4 { 1601 + t.Errorf("scroll size = %dx%d, want 5x4" , scrollDamage.Width, scrollDamage.Height) 1602 + } 1603 + 1604 + // Should also have text damage for the blanked rows (bottom 3 rows). 1605 + if !hasDamageKind(d, renderer.DamageText) { 1606 + t.Error("expected DamageText for blanked rows" ) 1607 + } 1608 + }, 1609 + }, 1610 + { 1611 + name: "cursor position is preserved across a scroll-up" , 1612 + run: func (t *testing.T) { 1613 + s := NewScreen(10, 5) 1614 + s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE" )) // fill all 5 rows 1615 + s.ClearDamage() 1616 + 1617 + // Move cursor to middle of row 2. 1618 + s.Write([]byte("\x1b[3;3H" )) // row=3, col=3 1619 + s.Write([]byte("\x1b[2S" )) // scroll up by 2 1620 + 1621 + // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2). 1622 + if s.Row != 2 || s.Col != 2 { 1623 + t.Errorf("cursor at row=%d col=%d, want row=2 col=2" , s.Row, s.Col) 1624 + } 1625 + }, 1626 + }, 1298 1627 } 1299 - } 1300 1628 1301 - func TestCSI_SCursorPreserved(t *testing.T) {1302 - s := NewScreen(10, 5) 1303 - s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE" )) // fill all 5 rows 1304 - s.ClearDamage() 1305 - 1306 - // Move cursor to middle of row 2. 1307 - s.Write([]byte("\x1b[3;3H" )) // row=3, col=3 1308 - s.Write([]byte("\x1b[2S" )) // scroll up by 2 1309 - 1310 - // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2). 1311 - if s.Row != 2 || s.Col != 2 { 1312 - t.Errorf("cursor at row=%d col=%d, want row=2 col=2" , s.Row, s.Col) 1629 + for _, tt := range tests { 1630 + t.Run(tt.name, tt.run) 1313 1631 } 1314 1632 } 1315 1633 ⋯ 1 hidden line@@ -1317,85 +1635,109 @@ func TestCSI_SCursorPreserved(t *testing.T) { 1317 1635 // OSC sequences 1318 1636 // --------------------------------------------------------------------------- 1319 1637 1320 - func TestOSC_BELTerminatedIgnored(t *testing.T) {1321 - s := NewScreen(20, 2) 1322 - s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ " )) 1323 - 1324 - assertCell(t, s, 0, 0, 'f' ) 1325 - assertCell(t, s, 1, 0, 'i' ) 1326 - assertCell(t, s, 2, 0, 's' ) 1327 - assertCell(t, s, 3, 0, 'h' ) 1328 - assertCell(t, s, 4, 0, '$' ) 1329 - assertCell(t, s, 5, 0, ' ' ) 1330 - if s.Col != 6 || s.Row != 0 { 1331 - t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1332 - } 1333 - } 1334 - 1335 - func TestOSC_STTerminatedIgnored(t *testing.T) {1336 - s := NewScreen(20, 2) 1337 - s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> " )) 1338 - 1339 - assertCell(t, s, 0, 0, '>' ) 1340 - assertCell(t, s, 1, 0, ' ' ) 1341 - if s.Col != 2 || s.Row != 0 { 1342 - t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 1343 - } 1344 - } 1345 - 1346 - func TestOSC_MultipleSequencesIgnored(t *testing.T) {1347 - s := NewScreen(20, 2) 1348 - s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt" )) 1349 - 1350 - assertCell(t, s, 0, 0, 'p' ) 1351 - assertCell(t, s, 1, 0, 'r' ) 1352 - assertCell(t, s, 2, 0, 'o' ) 1353 - assertCell(t, s, 3, 0, 'm' ) 1354 - assertCell(t, s, 4, 0, 'p' ) 1355 - assertCell(t, s, 5, 0, 't' ) 1356 - if s.Col != 6 || s.Row != 0 { 1357 - t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1358 - } 1359 - } 1360 - 1361 - func TestOSC_BELTerminatedSplitAcrossWritesIgnored(t *testing.T) {1362 - s := NewScreen(20, 2) 1363 - s.Write([]byte("\x1b]0;~/Projects/ymux" )) 1364 - s.Write([]byte(" - fish\x07fish$ " )) 1365 - 1366 - assertCell(t, s, 0, 0, 'f' ) 1367 - assertCell(t, s, 1, 0, 'i' ) 1368 - assertCell(t, s, 2, 0, 's' ) 1369 - assertCell(t, s, 3, 0, 'h' ) 1370 - assertCell(t, s, 4, 0, '$' ) 1371 - assertCell(t, s, 5, 0, ' ' ) 1372 - if s.Col != 6 || s.Row != 0 { 1373 - t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1374 - } 1375 - } 1376 - 1377 - func TestOSC_STTerminatedSplitAcrossWritesIgnored(t *testing.T) {1378 - s := NewScreen(20, 2) 1379 - s.Write([]byte("\x1b]133;A;click_events=1\x1b" )) 1380 - s.Write([]byte("\\> " )) 1381 - 1382 - assertCell(t, s, 0, 0, '>' ) 1383 - assertCell(t, s, 1, 0, ' ' ) 1384 - if s.Col != 2 || s.Row != 0 { 1385 - t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 1638 + func TestOSCSequences(t *testing.T) {1639 + tests := []struct { 1640 + name string 1641 + run func (t *testing.T) 1642 + }{ 1643 + { 1644 + name: "BEL-terminated OSC is ignored" , 1645 + run: func (t *testing.T) { 1646 + s := NewScreen(20, 2) 1647 + s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ " )) 1648 + 1649 + assertCell(t, s, 0, 0, 'f' ) 1650 + assertCell(t, s, 1, 0, 'i' ) 1651 + assertCell(t, s, 2, 0, 's' ) 1652 + assertCell(t, s, 3, 0, 'h' ) 1653 + assertCell(t, s, 4, 0, '$' ) 1654 + assertCell(t, s, 5, 0, ' ' ) 1655 + if s.Col != 6 || s.Row != 0 { 1656 + t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1657 + } 1658 + }, 1659 + }, 1660 + { 1661 + name: "ST-terminated OSC is ignored" , 1662 + run: func (t *testing.T) { 1663 + s := NewScreen(20, 2) 1664 + s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> " )) 1665 + 1666 + assertCell(t, s, 0, 0, '>' ) 1667 + assertCell(t, s, 1, 0, ' ' ) 1668 + if s.Col != 2 || s.Row != 0 { 1669 + t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 1670 + } 1671 + }, 1672 + }, 1673 + { 1674 + name: "multiple OSC sequences in a row are all ignored" , 1675 + run: func (t *testing.T) { 1676 + s := NewScreen(20, 2) 1677 + s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt" )) 1678 + 1679 + assertCell(t, s, 0, 0, 'p' ) 1680 + assertCell(t, s, 1, 0, 'r' ) 1681 + assertCell(t, s, 2, 0, 'o' ) 1682 + assertCell(t, s, 3, 0, 'm' ) 1683 + assertCell(t, s, 4, 0, 'p' ) 1684 + assertCell(t, s, 5, 0, 't' ) 1685 + if s.Col != 6 || s.Row != 0 { 1686 + t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1687 + } 1688 + }, 1689 + }, 1690 + { 1691 + name: "BEL-terminated OSC split across writes is ignored" , 1692 + run: func (t *testing.T) { 1693 + s := NewScreen(20, 2) 1694 + s.Write([]byte("\x1b]0;~/Projects/ymux" )) 1695 + s.Write([]byte(" - fish\x07fish$ " )) 1696 + 1697 + assertCell(t, s, 0, 0, 'f' ) 1698 + assertCell(t, s, 1, 0, 'i' ) 1699 + assertCell(t, s, 2, 0, 's' ) 1700 + assertCell(t, s, 3, 0, 'h' ) 1701 + assertCell(t, s, 4, 0, '$' ) 1702 + assertCell(t, s, 5, 0, ' ' ) 1703 + if s.Col != 6 || s.Row != 0 { 1704 + t.Errorf("cursor at col=%d row=%d, want col=6 row=0" , s.Col, s.Row) 1705 + } 1706 + }, 1707 + }, 1708 + { 1709 + name: "ST-terminated OSC split across writes is ignored" , 1710 + run: func (t *testing.T) { 1711 + s := NewScreen(20, 2) 1712 + s.Write([]byte("\x1b]133;A;click_events=1\x1b" )) 1713 + s.Write([]byte("\\> " )) 1714 + 1715 + assertCell(t, s, 0, 0, '>' ) 1716 + assertCell(t, s, 1, 0, ' ' ) 1717 + if s.Col != 2 || s.Row != 0 { 1718 + t.Errorf("cursor at col=%d row=%d, want col=2 row=0" , s.Col, s.Row) 1719 + } 1720 + }, 1721 + }, 1722 + { 1723 + name: "unterminated OSC sequence is bounded and dropped" , 1724 + run: func (t *testing.T) { 1725 + s := NewScreen(20, 2) 1726 + payload := strings.Repeat("x" , maxEscapeBufferLen+1) 1727 + s.Write([]byte("\x1b]0;" + payload)) 1728 + s.Write([]byte("OK" )) 1729 + 1730 + assertCell(t, s, 0, 0, 'O' ) 1731 + assertCell(t, s, 1, 0, 'K' ) 1732 + if len(s.escapeBuf) != 0 { 1733 + t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC" , len(s.escapeBuf)) 1734 + } 1735 + }, 1736 + }, 1386 1737 } 1387 - } 1388 - 1389 - func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) {1390 - s := NewScreen(20, 2) 1391 - payload := strings.Repeat("x" , maxEscapeBufferLen+1) 1392 - s.Write([]byte("\x1b]0;" + payload)) 1393 - s.Write([]byte("OK" )) 1394 1738 1395 - assertCell(t, s, 0, 0, 'O' ) 1396 - assertCell(t, s, 1, 0, 'K' ) 1397 - if len(s.escapeBuf) != 0 { 1398 - t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC" , len(s.escapeBuf)) 1739 + for _, tt := range tests { 1740 + t.Run(tt.name, tt.run) 1399 1741 } 1400 1742 } 1401 1743 ⋯ 1 hidden line@@ -1403,135 +1745,116 @@ func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) { 1403 1745 // C1 Unicode controls (U+0080-U+009F) 1404 1746 // --------------------------------------------------------------------------- 1405 1747 1406 - func TestC1ControlsDropped(t *testing.T) {1407 - s := NewScreen(10, 3) 1408 - s.Write([]byte{0xC2, 0x80}) // U+0080 PAD 1409 - s.Write([]byte{0xC2, 0x9F}) // U+009F APC 1410 - 1411 - // No cells should be modified. 1412 - for y := 0; y < 3; y++ { 1413 - for x := 0; x < 10; x++ { 1414 - if c := cellAt(s, x, y); c.Rune != ' ' { 1415 - t.Errorf("cell(%d,%d) = %q, want space after C1 controls" , x, y, c.Rune) 1416 - } 1417 - } 1418 - } 1419 - // Cursor should not have moved. 1420 - if s.Col != 0 || s.Row != 0 { 1421 - t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1422 - } 1423 - } 1424 - 1425 - func TestC1ControlsRange(t *testing.T) {1426 - s := NewScreen(10, 3) 1427 - // Send all C1 control runes U+0080 through U+009F. 1428 - for r := 0x80; r <= 0x9F; r++ { 1429 - s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)}) 1430 - } 1431 - 1432 - // No cells should be modified. 1433 - for y := 0; y < 3; y++ { 1434 - for x := 0; x < 10; x++ { 1435 - if c := cellAt(s, x, y); c.Rune != ' ' { 1436 - t.Errorf("cell(%d,%d) = %q, want space after C1 range" , x, y, c.Rune) 1437 - } 1438 - } 1439 - } 1440 - if s.Col != 0 || s.Row != 0 { 1441 - t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1442 - } 1443 - } 1444 - 1445 - func TestC1ControlsAfterPrintable(t *testing.T) {1446 - s := NewScreen(10, 3) 1447 - // Printable characters should still work after C1 controls. 1448 - s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1449 - s.Write([]byte("AB" )) 1450 - s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) 1451 - s.Write([]byte("CD" )) 1452 - 1453 - assertCell(t, s, 0, 0, 'A' ) 1454 - assertCell(t, s, 1, 0, 'B' ) 1455 - assertCell(t, s, 2, 0, 'C' ) 1456 - assertCell(t, s, 3, 0, 'D' ) 1457 - if s.Col != 4 { 1458 - t.Errorf("cursor at col=%d, want 4" , s.Col) 1459 - } 1460 - } 1461 - 1462 - func TestC1ControlsPreserveCRLF(t *testing.T) {1463 - s := NewScreen(10, 3) 1464 - // C1 controls mixed with CR/LF should preserve CR/LF behavior. 1465 - s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1466 - s.Write([]byte("A\rB" )) // CR moves cursor back, overwrite A 1467 - s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) 1468 - 1469 - assertCell(t, s, 0, 0, 'B' ) 1470 - } 1471 - 1472 - func TestC1ControlsPreserveLF(t *testing.T) {1473 - s := NewScreen(10, 3) 1474 - s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1475 - s.Write([]byte("A\nB" )) 1476 - 1477 - assertCell(t, s, 0, 0, 'A' ) 1478 - assertCell(t, s, 1, 1, 'B' ) 1479 - if s.Row != 1 || s.Col != 2 { 1480 - t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)" , s.Row, s.Col) 1481 - } 1482 - } 1483 - 1484 - func TestC1ControlsNoDamage(t *testing.T) {1485 - s := NewScreen(10, 3) 1486 - s.ClearDamage() 1487 - // C1 controls should produce no damage. 1488 - s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F}) 1489 - 1490 - d := s.Damage() 1491 - if len(d) != 0 { 1492 - t.Errorf("expected no damage from C1 controls, got %+v" , d) 1493 - } 1494 - } 1495 - 1496 - func TestEraseDisplayModes(t *testing.T) {1497 - s := NewScreen(5, 3) 1498 - s.Write([]byte("abcdefghijklmno" )) 1499 - s.Write([]byte("\x1b[2;3H" )) 1500 - s.ClearDamage() 1501 - s.Write([]byte("\x1b[J" )) 1502 - assertCell(t, s, 0, 0, 'a' ) 1503 - assertCell(t, s, 1, 1, 'g' ) 1504 - if c := cellAt(s, 2, 1); c.Rune != ' ' { 1505 - t.Fatalf("cursor-to-end clear left %q at cursor" , c.Rune) 1506 - } 1507 - 1508 - s = NewScreen(5, 3) 1509 - s.Write([]byte("abcdefghijklmno" )) 1510 - s.Write([]byte("\x1b[2;3H" )) 1511 - s.Write([]byte("\x1b[1J" )) 1512 - if c := cellAt(s, 0, 0); c.Rune != ' ' { 1513 - t.Fatalf("start-to-cursor clear left %q at start" , c.Rune) 1748 + func TestC1Controls(t *testing.T) {1749 + tests := []struct { 1750 + name string 1751 + run func (t *testing.T) 1752 + }{ 1753 + { 1754 + name: "C1 controls are dropped without moving the cursor" , 1755 + run: func (t *testing.T) { 1756 + s := NewScreen(10, 3) 1757 + s.Write([]byte{0xC2, 0x80}) // U+0080 PAD 1758 + s.Write([]byte{0xC2, 0x9F}) // U+009F APC 1759 + 1760 + // No cells should be modified. 1761 + for y := 0; y < 3; y++ { 1762 + for x := 0; x < 10; x++ { 1763 + if c := cellAt(s, x, y); c.Rune != ' ' { 1764 + t.Errorf("cell(%d,%d) = %q, want space after C1 controls" , x, y, c.Rune) 1765 + } 1766 + } 1767 + } 1768 + // Cursor should not have moved. 1769 + if s.Col != 0 || s.Row != 0 { 1770 + t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1771 + } 1772 + }, 1773 + }, 1774 + { 1775 + name: "the full C1 control range U+0080-U+009F is dropped" , 1776 + run: func (t *testing.T) { 1777 + s := NewScreen(10, 3) 1778 + // Send all C1 control runes U+0080 through U+009F. 1779 + for r := 0x80; r <= 0x9F; r++ { 1780 + s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)}) 1781 + } 1782 + 1783 + // No cells should be modified. 1784 + for y := 0; y < 3; y++ { 1785 + for x := 0; x < 10; x++ { 1786 + if c := cellAt(s, x, y); c.Rune != ' ' { 1787 + t.Errorf("cell(%d,%d) = %q, want space after C1 range" , x, y, c.Rune) 1788 + } 1789 + } 1790 + } 1791 + if s.Col != 0 || s.Row != 0 { 1792 + t.Errorf("cursor at col=%d row=%d, want col=0 row=0" , s.Col, s.Row) 1793 + } 1794 + }, 1795 + }, 1796 + { 1797 + name: "printable characters still work after C1 controls" , 1798 + run: func (t *testing.T) { 1799 + s := NewScreen(10, 3) 1800 + // Printable characters should still work after C1 controls. 1801 + s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1802 + s.Write([]byte("AB" )) 1803 + s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) 1804 + s.Write([]byte("CD" )) 1805 + 1806 + assertCell(t, s, 0, 0, 'A' ) 1807 + assertCell(t, s, 1, 0, 'B' ) 1808 + assertCell(t, s, 2, 0, 'C' ) 1809 + assertCell(t, s, 3, 0, 'D' ) 1810 + if s.Col != 4 { 1811 + t.Errorf("cursor at col=%d, want 4" , s.Col) 1812 + } 1813 + }, 1814 + }, 1815 + { 1816 + name: "C1 controls mixed with CR preserve CR behavior" , 1817 + run: func (t *testing.T) { 1818 + s := NewScreen(10, 3) 1819 + // C1 controls mixed with CR/LF should preserve CR/LF behavior. 1820 + s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1821 + s.Write([]byte("A\rB" )) // CR moves cursor back, overwrite A 1822 + s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) 1823 + 1824 + assertCell(t, s, 0, 0, 'B' ) 1825 + }, 1826 + }, 1827 + { 1828 + name: "C1 controls mixed with LF preserve LF behavior" , 1829 + run: func (t *testing.T) { 1830 + s := NewScreen(10, 3) 1831 + s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) 1832 + s.Write([]byte("A\nB" )) 1833 + 1834 + assertCell(t, s, 0, 0, 'A' ) 1835 + assertCell(t, s, 1, 1, 'B' ) 1836 + if s.Row != 1 || s.Col != 2 { 1837 + t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)" , s.Row, s.Col) 1838 + } 1839 + }, 1840 + }, 1841 + { 1842 + name: "C1 controls produce no damage" , 1843 + run: func (t *testing.T) { 1844 + s := NewScreen(10, 3) 1845 + s.ClearDamage() 1846 + // C1 controls should produce no damage. 1847 + s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F}) 1848 + 1849 + d := s.Damage() 1850 + if len(d) != 0 { 1851 + t.Errorf("expected no damage from C1 controls, got %+v" , d) 1852 + } 1853 + }, 1854 + }, 1514 1855 } 1515 - assertCell(t, s, 3, 1, 'i' ) 1516 - } 1517 1856 1518 - func TestEraseLineModes(t *testing.T) {1519 - s := NewScreen(5, 2) 1520 - s.Write([]byte("abcde" )) 1521 - s.Write([]byte("\x1b[1;3H" )) 1522 - s.Write([]byte("\x1b[1K" )) 1523 - if c := cellAt(s, 0, 0); c.Rune != ' ' { 1524 - t.Fatalf("line start clear left %q" , c.Rune) 1525 - } 1526 - assertCell(t, s, 3, 0, 'd' ) 1527 - 1528 - s = NewScreen(5, 2) 1529 - s.Write([]byte("abcde" )) 1530 - s.Write([]byte("\x1b[1;3H" )) 1531 - s.Write([]byte("\x1b[2K" )) 1532 - for x := 0; x < 5; x++ { 1533 - if c := cellAt(s, x, 0); c.Rune != ' ' { 1534 - t.Fatalf("line clear left %q at %d" , c.Rune, x) 1535 - } 1857 + for _, tt := range tests { 1858 + t.Run(tt.name, tt.run) 1536 1859 } 1537 1860 }