refactor(vt): split screen state operations
- commit
- 58fcde1e64de233eb1189926045014ab0fc06d84
- author
- brice · · 2026-07-14 20:23 UTC
- parent
- 86b7f27
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +512 -500
M pkg/vt/screen.go +0 -500
| @@ -6,28 +6,12 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | type screenState struct { | |
| 10 | - | frame renderer.Frame | |
| 11 | - | row int | |
| 12 | - | col int | |
| 13 | - | style renderer.Style | |
| 14 | - | scrollTop int | |
| 15 | - | scrollBottom int | |
| 16 | - | savedCursor cursorState | |
| 17 | - | originMode bool | |
| 18 | - | insertMode bool | |
| 19 | - | } | |
| 20 | - | ||
| 21 | 9 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | |
| 22 | 10 | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | |
| 23 | 11 | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | |
| 24 | 12 | // so keep this value in sync if that payload cap changes. | |
| 25 | 13 | const maxEscapeBufferLen = 128 * 1024 | |
| 26 | 14 | ||
| 27 | - | // maxPendingDamage bounds metadata retained while no render transaction can | |
| 28 | - | // acknowledge a screen. Saturation falls back to one exact full redraw. | |
| 29 | - | const maxPendingDamage = 1024 | |
| 30 | - | ||
| 31 | 15 | const ( | |
| 32 | 16 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | |
| 33 | 17 | ColorSchemeReportDark = "\x1b[?997;1n" | |
| @@ -124,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||
| 124 | 108 | // configured with NewScreenWithHistory. | |
| 125 | 109 | func (s *Screen) History() *History { return s.history } | |
| 126 | 110 | ||
| 127 | - | func (s *Screen) Resize(width, height int) { | |
| 128 | - | if width == s.Frame.Width && height == s.Frame.Height { | |
| 129 | - | return | |
| 130 | - | } | |
| 131 | - | evict := s.recordEvicted | |
| 132 | - | ||
| 133 | - | if s.alternate != nil { | |
| 134 | - | var shift int | |
| 135 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |
| 136 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 137 | - | s.Col = clamp(s.Col, 0, width-1) | |
| 138 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 139 | - | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |
| 140 | - | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |
| 141 | - | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |
| 142 | - | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |
| 143 | - | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |
| 144 | - | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |
| 145 | - | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |
| 146 | - | s.alternate.scrollTop = 0 | |
| 147 | - | s.alternate.scrollBottom = max(height-1, 0) | |
| 148 | - | } | |
| 149 | - | } else { | |
| 150 | - | var shift int | |
| 151 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |
| 152 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 153 | - | s.Col = clamp(s.Col, 0, width-1) | |
| 154 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | // A resize can split an in-flight escape sequence from the terminal state it | |
| 158 | - | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 159 | - | s.escapeBuf = s.escapeBuf[:0] | |
| 160 | - | s.resetScrollRegion() | |
| 161 | - | s.fullRedraw() | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // DamageCapture is an immutable copy of pending damage at one screen generation. | |
| 165 | - | type DamageCapture struct { | |
| 166 | - | Damage []renderer.Damage | |
| 167 | - | Generation uint64 | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Damage returns the current damage list. The caller must not modify the | |
| 171 | - | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 172 | - | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |
| 173 | - | func (s *Screen) ClearDamage() { | |
| 174 | - | s.damage = s.damage[:0] | |
| 175 | - | s.damageSaturated = false | |
| 176 | - | s.damageFullRedrawSticky = false | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // CaptureDamage snapshots pending damage without consuming it. | |
| 180 | - | func (s *Screen) CaptureDamage() DamageCapture { | |
| 181 | - | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |
| 185 | - | // since it was taken. A stale acknowledgement conservatively requests a full | |
| 186 | - | // redraw, ensuring intervening writes remain visible to the next capture. | |
| 187 | - | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |
| 188 | - | if generation != s.damageGeneration { | |
| 189 | - | s.fullRedraw() | |
| 190 | - | return false | |
| 191 | - | } | |
| 192 | - | s.ClearDamage() | |
| 193 | - | return true | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| 197 | - | // is currently enabled by the child process. | |
| 198 | - | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |
| 199 | - | ||
| 200 | - | // BracketedPasteMode reports whether DEC private mode 2004 is currently enabled | |
| 201 | - | // by the child process. | |
| 202 | - | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |
| 203 | - | ||
| 204 | - | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |
| 205 | - | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |
| 206 | - | ||
| 207 | - | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |
| 208 | - | func (s *Screen) SetColorScheme(light bool) { | |
| 209 | - | if s.colorSchemeSet && s.colorSchemeLight == light { | |
| 210 | - | return | |
| 211 | - | } | |
| 212 | - | s.colorSchemeSet = true | |
| 213 | - | s.colorSchemeLight = light | |
| 214 | - | if s.colorSchemeMode { | |
| 215 | - | s.respond(s.colorSchemeReport()) | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // ClearColorScheme marks the host color scheme as unknown. Future child color | |
| 220 | - | // scheme queries are silent until SetColorScheme supplies a known value again. | |
| 221 | - | func (s *Screen) ClearColorScheme() { | |
| 222 | - | s.colorSchemeSet = false | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |
| 226 | - | // Hosts use this as a safety valve if a child enters synchronized update mode | |
| 227 | - | // and never sends the matching end sequence. | |
| 228 | - | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |
| 229 | - | ||
| 230 | - | // SetDefaultColors sets the terminal default foreground/background colors used | |
| 231 | - | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |
| 232 | - | // silent until known colors are supplied again. | |
| 233 | - | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |
| 234 | - | s.defaultFG = fg | |
| 235 | - | s.defaultBG = bg | |
| 236 | - | s.defaultColorsKnown = ok | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | func (s *Screen) CursorRow() int { return s.Row } | |
| 240 | - | func (s *Screen) CursorCol() int { return s.Col } | |
| 241 | - | func (s *Screen) CursorVisible() bool { | |
| 242 | - | return s.cursorVisible | |
| 243 | - | } | |
| 244 | - | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |
| 245 | - | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |
| 246 | - | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |
| 247 | - | ||
| 248 | - | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |
| 249 | - | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |
| 250 | - | ||
| 251 | 111 | func (s *Screen) Write(data []byte) { | |
| 252 | 112 | if len(s.escapeBuf) > 0 { | |
| 253 | 113 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| @@ -281,173 +141,6 @@ func (s *Screen) Write(data []byte) { | |||
| 281 | 141 | } | |
| 282 | 142 | } | |
| 283 | 143 | ||
| 284 | - | func (s *Screen) index() { | |
| 285 | - | if s.Frame.Height == 0 { | |
| 286 | - | return | |
| 287 | - | } | |
| 288 | - | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |
| 289 | - | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |
| 290 | - | return | |
| 291 | - | } | |
| 292 | - | if s.Row+1 < s.Frame.Height { | |
| 293 | - | s.Row++ | |
| 294 | - | return | |
| 295 | - | } | |
| 296 | - | s.Row = s.Frame.Height - 1 | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | func (s *Screen) nextLine() { | |
| 300 | - | s.Col = 0 | |
| 301 | - | s.index() | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | func (s *Screen) reverseIndex() { | |
| 305 | - | if s.Frame.Height == 0 { | |
| 306 | - | return | |
| 307 | - | } | |
| 308 | - | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |
| 309 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |
| 310 | - | return | |
| 311 | - | } | |
| 312 | - | if s.Row > 0 { | |
| 313 | - | s.Row-- | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |
| 318 | - | // The cursor position is preserved. | |
| 319 | - | func (s *Screen) scrollUpBy(n int) { | |
| 320 | - | if n <= 0 { | |
| 321 | - | return | |
| 322 | - | } | |
| 323 | - | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | func (s *Screen) scrollDownBy(n int) { | |
| 327 | - | if n <= 0 { | |
| 328 | - | return | |
| 329 | - | } | |
| 330 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |
| 334 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 335 | - | return | |
| 336 | - | } | |
| 337 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 338 | - | if !ok { | |
| 339 | - | return | |
| 340 | - | } | |
| 341 | - | w := s.Frame.Width | |
| 342 | - | height := bottom - top + 1 | |
| 343 | - | if n > height { | |
| 344 | - | n = height | |
| 345 | - | } | |
| 346 | - | // VT scroll regions always span the full frame width, so we rotate the | |
| 347 | - | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 348 | - | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 349 | - | s.emitLineEvicted(top, n) | |
| 350 | - | s.Frame.ScrollUp(top, bottom, n) | |
| 351 | - | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 352 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | func (s *Screen) emitLineEvicted(top, n int) { | |
| 356 | - | if s.alternate != nil { | |
| 357 | - | return | |
| 358 | - | } | |
| 359 | - | for y := top; y < top+n; y++ { | |
| 360 | - | s.recordEvicted(s.Frame.Row(y)) | |
| 361 | - | } | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 365 | - | if s.history != nil { | |
| 366 | - | s.history.Append(row) | |
| 367 | - | } | |
| 368 | - | if s.OnLineEvicted != nil { | |
| 369 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| 370 | - | } | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | func (s *Screen) respond(b []byte) { | |
| 374 | - | if s.OnResponse != nil { | |
| 375 | - | s.OnResponse(b) | |
| 376 | - | } | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | func (s *Screen) colorSchemeReport() []byte { | |
| 380 | - | if !s.colorSchemeSet { | |
| 381 | - | return nil | |
| 382 | - | } | |
| 383 | - | if s.colorSchemeLight { | |
| 384 | - | return []byte(ColorSchemeReportLight) | |
| 385 | - | } | |
| 386 | - | return []byte(ColorSchemeReportDark) | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 390 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 391 | - | return | |
| 392 | - | } | |
| 393 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 394 | - | if !ok { | |
| 395 | - | return | |
| 396 | - | } | |
| 397 | - | w := s.Frame.Width | |
| 398 | - | height := bottom - top + 1 | |
| 399 | - | if n > height { | |
| 400 | - | n = height | |
| 401 | - | } | |
| 402 | - | // Full-width region: rotate line offsets instead of copying cells. | |
| 403 | - | s.Frame.ScrollDown(top, bottom, n) | |
| 404 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |
| 408 | - | if s.Frame.Height == 0 || top > bottom { | |
| 409 | - | return 0, 0, false | |
| 410 | - | } | |
| 411 | - | top = clamp(top, 0, s.Frame.Height-1) | |
| 412 | - | bottom = clamp(bottom, 0, s.Frame.Height-1) | |
| 413 | - | return top, bottom, top <= bottom | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | func (s *Screen) record(d renderer.Damage) { | |
| 417 | - | s.damageGeneration++ | |
| 418 | - | if s.damageSaturated || s.damageFullRedrawSticky { | |
| 419 | - | return | |
| 420 | - | } | |
| 421 | - | if len(s.damage) >= maxPendingDamage { | |
| 422 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 423 | - | s.damageSaturated = true | |
| 424 | - | return | |
| 425 | - | } | |
| 426 | - | // Replace FullRedraw with the first concrete damage item. | |
| 427 | - | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 428 | - | s.damage[0] = d | |
| 429 | - | return | |
| 430 | - | } | |
| 431 | - | // Coalesce adjacent single-cell text damage on the same line. | |
| 432 | - | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |
| 433 | - | last := &s.damage[len(s.damage)-1] | |
| 434 | - | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |
| 435 | - | last.Width++ | |
| 436 | - | return | |
| 437 | - | } | |
| 438 | - | } | |
| 439 | - | s.damage = append(s.damage, d) | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | func (s *Screen) fullRedraw() { | |
| 443 | - | s.damageGeneration++ | |
| 444 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 445 | - | // A structural frame replacement (such as DEC 1049 exit) cannot be | |
| 446 | - | // represented by the following text damage alone. Retain the redraw until | |
| 447 | - | // the render owner acknowledges this screen generation. | |
| 448 | - | s.damageFullRedrawSticky = true | |
| 449 | - | } | |
| 450 | - | ||
| 451 | 144 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |
| 452 | 145 | if len(data) < 2 { | |
| 453 | 146 | return 0, true | |
| @@ -506,196 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||
| 506 | 199 | } | |
| 507 | 200 | return 0, true | |
| 508 | 201 | } | |
| 509 | - | ||
| 510 | - | func (s *Screen) reset() { | |
| 511 | - | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 512 | - | s.Row, s.Col = 0, 0 | |
| 513 | - | s.Style = renderer.DefaultStyle() | |
| 514 | - | s.escapeBuf = s.escapeBuf[:0] | |
| 515 | - | s.savedCursor = cursorState{} | |
| 516 | - | s.alternate = nil | |
| 517 | - | s.originMode = false | |
| 518 | - | s.insertMode = false | |
| 519 | - | s.cursorVisible = true | |
| 520 | - | s.cursorStyle = 0 | |
| 521 | - | s.cursorStyleSet = false | |
| 522 | - | s.mouseMode = 0 | |
| 523 | - | s.mouseSGR = false | |
| 524 | - | s.bracketedPaste = false | |
| 525 | - | s.colorSchemeMode = false | |
| 526 | - | s.resetScrollRegion() | |
| 527 | - | s.fullRedraw() | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | func (s *Screen) setScrollRegion(parts []int) { | |
| 531 | - | if s.Frame.Height == 0 { | |
| 532 | - | return | |
| 533 | - | } | |
| 534 | - | top, bottom := 1, s.Frame.Height | |
| 535 | - | if len(parts) > 0 && parts[0] > 0 { | |
| 536 | - | top = parts[0] | |
| 537 | - | } | |
| 538 | - | if len(parts) > 1 && parts[1] > 0 { | |
| 539 | - | bottom = parts[1] | |
| 540 | - | } | |
| 541 | - | if top >= bottom { | |
| 542 | - | s.resetScrollRegion() | |
| 543 | - | } else { | |
| 544 | - | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |
| 545 | - | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |
| 546 | - | if s.scrollTop >= s.scrollBottom { | |
| 547 | - | s.resetScrollRegion() | |
| 548 | - | } | |
| 549 | - | } | |
| 550 | - | s.homeCursor() | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | func (s *Screen) resetScrollRegion() { | |
| 554 | - | s.scrollTop = 0 | |
| 555 | - | if s.Frame.Height > 0 { | |
| 556 | - | s.scrollBottom = s.Frame.Height - 1 | |
| 557 | - | } else { | |
| 558 | - | s.scrollBottom = 0 | |
| 559 | - | } | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |
| 563 | - | if !private { | |
| 564 | - | for _, mode := range parts { | |
| 565 | - | switch mode { | |
| 566 | - | case 4: | |
| 567 | - | s.insertMode = enabled | |
| 568 | - | default: | |
| 569 | - | // Other ANSI modes (for example LNM) are intentionally ignored | |
| 570 | - | // until the screen model implements their observable behavior. | |
| 571 | - | continue | |
| 572 | - | } | |
| 573 | - | } | |
| 574 | - | return | |
| 575 | - | } | |
| 576 | - | for _, mode := range parts { | |
| 577 | - | switch mode { | |
| 578 | - | case 6: | |
| 579 | - | s.originMode = enabled | |
| 580 | - | s.homeCursor() | |
| 581 | - | case 47, 1047, 1049: | |
| 582 | - | if enabled { | |
| 583 | - | s.enterAlternateScreen() | |
| 584 | - | } else { | |
| 585 | - | s.exitAlternateScreen() | |
| 586 | - | } | |
| 587 | - | case 2026: | |
| 588 | - | s.syncUpdateActive = enabled | |
| 589 | - | case 2031: | |
| 590 | - | s.colorSchemeMode = enabled | |
| 591 | - | case 25: | |
| 592 | - | s.cursorVisible = enabled | |
| 593 | - | case 1000, 1002, 1003: | |
| 594 | - | if enabled { | |
| 595 | - | s.mouseMode = mode | |
| 596 | - | } else if s.mouseMode == mode { | |
| 597 | - | s.mouseMode = 0 | |
| 598 | - | } | |
| 599 | - | case 1006: | |
| 600 | - | s.mouseSGR = enabled | |
| 601 | - | case 2004: | |
| 602 | - | s.bracketedPaste = enabled | |
| 603 | - | case 1, 1004, 1005: | |
| 604 | - | // Trackable terminal modes that do not directly affect the current | |
| 605 | - | // cell model yet. Consuming them prevents mode bytes from leaking. | |
| 606 | - | continue | |
| 607 | - | } | |
| 608 | - | } | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | func (s *Screen) enterAlternateScreen() { | |
| 612 | - | if s.alternate == nil { | |
| 613 | - | s.alternate = &screenState{ | |
| 614 | - | frame: cloneFrame(s.Frame), | |
| 615 | - | row: s.Row, | |
| 616 | - | col: s.Col, | |
| 617 | - | style: s.Style, | |
| 618 | - | scrollTop: s.scrollTop, | |
| 619 | - | scrollBottom: s.scrollBottom, | |
| 620 | - | savedCursor: s.savedCursor, | |
| 621 | - | originMode: s.originMode, | |
| 622 | - | insertMode: s.insertMode, | |
| 623 | - | } | |
| 624 | - | } | |
| 625 | - | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 626 | - | s.Row, s.Col = 0, 0 | |
| 627 | - | s.Style = renderer.DefaultStyle() | |
| 628 | - | s.savedCursor = cursorState{} | |
| 629 | - | s.resetScrollRegion() | |
| 630 | - | s.fullRedraw() | |
| 631 | - | } | |
| 632 | - | ||
| 633 | - | func (s *Screen) exitAlternateScreen() { | |
| 634 | - | if s.alternate == nil { | |
| 635 | - | return | |
| 636 | - | } | |
| 637 | - | state := s.alternate | |
| 638 | - | s.Frame = cloneFrame(state.frame) | |
| 639 | - | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| 640 | - | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |
| 641 | - | s.Style = state.style | |
| 642 | - | s.scrollTop = state.scrollTop | |
| 643 | - | s.scrollBottom = state.scrollBottom | |
| 644 | - | s.savedCursor = state.savedCursor | |
| 645 | - | s.originMode = state.originMode | |
| 646 | - | s.insertMode = state.insertMode | |
| 647 | - | s.alternate = nil | |
| 648 | - | s.fullRedraw() | |
| 649 | - | } | |
| 650 | - | ||
| 651 | - | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |
| 652 | - | next := renderer.NewFrame(newW, newH) | |
| 653 | - | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 654 | - | if evict != nil { | |
| 655 | - | for y := range shift { | |
| 656 | - | evict(old.Row(y)) | |
| 657 | - | } | |
| 658 | - | } | |
| 659 | - | for dy := range newH { | |
| 660 | - | sy := dy + shift | |
| 661 | - | if sy >= old.Height { | |
| 662 | - | break | |
| 663 | - | } | |
| 664 | - | copy(next.Row(dy), old.Row(sy)) | |
| 665 | - | repairFrameRow(next, dy) | |
| 666 | - | } | |
| 667 | - | return next, shift | |
| 668 | - | } | |
| 669 | - | ||
| 670 | - | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |
| 671 | - | if !cur.saved { | |
| 672 | - | return cur | |
| 673 | - | } | |
| 674 | - | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 675 | - | cur.col = clamp(cur.col, 0, width-1) | |
| 676 | - | return cur | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 680 | - | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 681 | - | // canonical, so a rotated source is normalized in the clone. | |
| 682 | - | func cloneFrame(frame renderer.Frame) renderer.Frame { | |
| 683 | - | out := renderer.NewFrame(frame.Width, frame.Height) | |
| 684 | - | for y := range frame.Height { | |
| 685 | - | copy(out.Row(y), frame.Row(y)) | |
| 686 | - | } | |
| 687 | - | return out | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | func clamp(v, lo, hi int) int { | |
| 691 | - | if hi < lo { | |
| 692 | - | return lo | |
| 693 | - | } | |
| 694 | - | if v < lo { | |
| 695 | - | return lo | |
| 696 | - | } | |
| 697 | - | if v > hi { | |
| 698 | - | return hi | |
| 699 | - | } | |
| 700 | - | return v | |
| 701 | - | } | |
| @@ -6,28 +6,12 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | type screenState struct { | |||
| 10 | - | frame renderer.Frame | |||
| 11 | - | row int | |||
| 12 | - | col int | |||
| 13 | - | style renderer.Style | |||
| 14 | - | scrollTop int | |||
| 15 | - | scrollBottom int | |||
| 16 | - | savedCursor cursorState | |||
| 17 | - | originMode bool | |||
| 18 | - | insertMode bool | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | 9 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | ||
| 22 | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | 10 | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | ||
| 23 | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | 11 | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | ||
| 24 | // so keep this value in sync if that payload cap changes. | 12 | // so keep this value in sync if that payload cap changes. | ||
| 25 | const maxEscapeBufferLen = 128 * 1024 | 13 | const maxEscapeBufferLen = 128 * 1024 | ||
| 26 | 14 | ||||
| 27 | - | // maxPendingDamage bounds metadata retained while no render transaction can | |||
| 28 | - | // acknowledge a screen. Saturation falls back to one exact full redraw. | |||
| 29 | - | const maxPendingDamage = 1024 | |||
| 30 | - | ||||
| 31 | const ( | 15 | const ( | ||
| 32 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | 16 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | ||
| 33 | ColorSchemeReportDark = "\x1b[?997;1n" | 17 | ColorSchemeReportDark = "\x1b[?997;1n" | ||
| @@ -124,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||||
| 124 | // configured with NewScreenWithHistory. | 108 | // configured with NewScreenWithHistory. | ||
| 125 | func (s *Screen) History() *History { return s.history } | 109 | func (s *Screen) History() *History { return s.history } | ||
| 126 | 110 | ||||
| 127 | - | func (s *Screen) Resize(width, height int) { | |||
| 128 | - | if width == s.Frame.Width && height == s.Frame.Height { | |||
| 129 | - | return | |||
| 130 | - | } | |||
| 131 | - | evict := s.recordEvicted | |||
| 132 | - | ||||
| 133 | - | if s.alternate != nil { | |||
| 134 | - | var shift int | |||
| 135 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |||
| 136 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 137 | - | s.Col = clamp(s.Col, 0, width-1) | |||
| 138 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 139 | - | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |||
| 140 | - | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |||
| 141 | - | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |||
| 142 | - | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |||
| 143 | - | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |||
| 144 | - | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |||
| 145 | - | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |||
| 146 | - | s.alternate.scrollTop = 0 | |||
| 147 | - | s.alternate.scrollBottom = max(height-1, 0) | |||
| 148 | - | } | |||
| 149 | - | } else { | |||
| 150 | - | var shift int | |||
| 151 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |||
| 152 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 153 | - | s.Col = clamp(s.Col, 0, width-1) | |||
| 154 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | // A resize can split an in-flight escape sequence from the terminal state it | |||
| 158 | - | // was meant to mutate; keep the durable child state but discard partial bytes. | |||
| 159 | - | s.escapeBuf = s.escapeBuf[:0] | |||
| 160 | - | s.resetScrollRegion() | |||
| 161 | - | s.fullRedraw() | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // DamageCapture is an immutable copy of pending damage at one screen generation. | |||
| 165 | - | type DamageCapture struct { | |||
| 166 | - | Damage []renderer.Damage | |||
| 167 | - | Generation uint64 | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Damage returns the current damage list. The caller must not modify the | |||
| 171 | - | // returned slice; ClearDamage must be called after the damage is consumed. | |||
| 172 | - | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 173 | - | func (s *Screen) ClearDamage() { | |||
| 174 | - | s.damage = s.damage[:0] | |||
| 175 | - | s.damageSaturated = false | |||
| 176 | - | s.damageFullRedrawSticky = false | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // CaptureDamage snapshots pending damage without consuming it. | |||
| 180 | - | func (s *Screen) CaptureDamage() DamageCapture { | |||
| 181 | - | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |||
| 185 | - | // since it was taken. A stale acknowledgement conservatively requests a full | |||
| 186 | - | // redraw, ensuring intervening writes remain visible to the next capture. | |||
| 187 | - | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |||
| 188 | - | if generation != s.damageGeneration { | |||
| 189 | - | s.fullRedraw() | |||
| 190 | - | return false | |||
| 191 | - | } | |||
| 192 | - | s.ClearDamage() | |||
| 193 | - | return true | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |||
| 197 | - | // is currently enabled by the child process. | |||
| 198 | - | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 199 | - | ||||
| 200 | - | // BracketedPasteMode reports whether DEC private mode 2004 is currently enabled | |||
| 201 | - | // by the child process. | |||
| 202 | - | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |||
| 203 | - | ||||
| 204 | - | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |||
| 205 | - | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |||
| 206 | - | ||||
| 207 | - | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |||
| 208 | - | func (s *Screen) SetColorScheme(light bool) { | |||
| 209 | - | if s.colorSchemeSet && s.colorSchemeLight == light { | |||
| 210 | - | return | |||
| 211 | - | } | |||
| 212 | - | s.colorSchemeSet = true | |||
| 213 | - | s.colorSchemeLight = light | |||
| 214 | - | if s.colorSchemeMode { | |||
| 215 | - | s.respond(s.colorSchemeReport()) | |||
| 216 | - | } | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | // ClearColorScheme marks the host color scheme as unknown. Future child color | |||
| 220 | - | // scheme queries are silent until SetColorScheme supplies a known value again. | |||
| 221 | - | func (s *Screen) ClearColorScheme() { | |||
| 222 | - | s.colorSchemeSet = false | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |||
| 226 | - | // Hosts use this as a safety valve if a child enters synchronized update mode | |||
| 227 | - | // and never sends the matching end sequence. | |||
| 228 | - | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |||
| 229 | - | ||||
| 230 | - | // SetDefaultColors sets the terminal default foreground/background colors used | |||
| 231 | - | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |||
| 232 | - | // silent until known colors are supplied again. | |||
| 233 | - | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |||
| 234 | - | s.defaultFG = fg | |||
| 235 | - | s.defaultBG = bg | |||
| 236 | - | s.defaultColorsKnown = ok | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | func (s *Screen) CursorRow() int { return s.Row } | |||
| 240 | - | func (s *Screen) CursorCol() int { return s.Col } | |||
| 241 | - | func (s *Screen) CursorVisible() bool { | |||
| 242 | - | return s.cursorVisible | |||
| 243 | - | } | |||
| 244 | - | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |||
| 245 | - | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |||
| 246 | - | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |||
| 247 | - | ||||
| 248 | - | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |||
| 249 | - | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |||
| 250 | - | ||||
| 251 | func (s *Screen) Write(data []byte) { | 111 | func (s *Screen) Write(data []byte) { | ||
| 252 | if len(s.escapeBuf) > 0 { | 112 | if len(s.escapeBuf) > 0 { | ||
| 253 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 113 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | ||
| @@ -281,173 +141,6 @@ func (s *Screen) Write(data []byte) { | |||||
| 281 | } | 141 | } | ||
| 282 | } | 142 | } | ||
| 283 | 143 | ||||
| 284 | - | func (s *Screen) index() { | |||
| 285 | - | if s.Frame.Height == 0 { | |||
| 286 | - | return | |||
| 287 | - | } | |||
| 288 | - | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |||
| 289 | - | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 290 | - | return | |||
| 291 | - | } | |||
| 292 | - | if s.Row+1 < s.Frame.Height { | |||
| 293 | - | s.Row++ | |||
| 294 | - | return | |||
| 295 | - | } | |||
| 296 | - | s.Row = s.Frame.Height - 1 | |||
| 297 | - | } | |||
| 298 | - | ||||
| 299 | - | func (s *Screen) nextLine() { | |||
| 300 | - | s.Col = 0 | |||
| 301 | - | s.index() | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | func (s *Screen) reverseIndex() { | |||
| 305 | - | if s.Frame.Height == 0 { | |||
| 306 | - | return | |||
| 307 | - | } | |||
| 308 | - | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |||
| 309 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 310 | - | return | |||
| 311 | - | } | |||
| 312 | - | if s.Row > 0 { | |||
| 313 | - | s.Row-- | |||
| 314 | - | } | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |||
| 318 | - | // The cursor position is preserved. | |||
| 319 | - | func (s *Screen) scrollUpBy(n int) { | |||
| 320 | - | if n <= 0 { | |||
| 321 | - | return | |||
| 322 | - | } | |||
| 323 | - | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | func (s *Screen) scrollDownBy(n int) { | |||
| 327 | - | if n <= 0 { | |||
| 328 | - | return | |||
| 329 | - | } | |||
| 330 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 334 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 335 | - | return | |||
| 336 | - | } | |||
| 337 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 338 | - | if !ok { | |||
| 339 | - | return | |||
| 340 | - | } | |||
| 341 | - | w := s.Frame.Width | |||
| 342 | - | height := bottom - top + 1 | |||
| 343 | - | if n > height { | |||
| 344 | - | n = height | |||
| 345 | - | } | |||
| 346 | - | // VT scroll regions always span the full frame width, so we rotate the | |||
| 347 | - | // frame's line offsets (recycling and blanking the evicted rows in place) | |||
| 348 | - | // instead of copying cells. See renderer.Frame.ScrollUp. | |||
| 349 | - | s.emitLineEvicted(top, n) | |||
| 350 | - | s.Frame.ScrollUp(top, bottom, n) | |||
| 351 | - | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |||
| 352 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |||
| 353 | - | } | |||
| 354 | - | ||||
| 355 | - | func (s *Screen) emitLineEvicted(top, n int) { | |||
| 356 | - | if s.alternate != nil { | |||
| 357 | - | return | |||
| 358 | - | } | |||
| 359 | - | for y := top; y < top+n; y++ { | |||
| 360 | - | s.recordEvicted(s.Frame.Row(y)) | |||
| 361 | - | } | |||
| 362 | - | } | |||
| 363 | - | ||||
| 364 | - | func (s *Screen) recordEvicted(row []renderer.Cell) { | |||
| 365 | - | if s.history != nil { | |||
| 366 | - | s.history.Append(row) | |||
| 367 | - | } | |||
| 368 | - | if s.OnLineEvicted != nil { | |||
| 369 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 370 | - | } | |||
| 371 | - | } | |||
| 372 | - | ||||
| 373 | - | func (s *Screen) respond(b []byte) { | |||
| 374 | - | if s.OnResponse != nil { | |||
| 375 | - | s.OnResponse(b) | |||
| 376 | - | } | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | func (s *Screen) colorSchemeReport() []byte { | |||
| 380 | - | if !s.colorSchemeSet { | |||
| 381 | - | return nil | |||
| 382 | - | } | |||
| 383 | - | if s.colorSchemeLight { | |||
| 384 | - | return []byte(ColorSchemeReportLight) | |||
| 385 | - | } | |||
| 386 | - | return []byte(ColorSchemeReportDark) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 390 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 391 | - | return | |||
| 392 | - | } | |||
| 393 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 394 | - | if !ok { | |||
| 395 | - | return | |||
| 396 | - | } | |||
| 397 | - | w := s.Frame.Width | |||
| 398 | - | height := bottom - top + 1 | |||
| 399 | - | if n > height { | |||
| 400 | - | n = height | |||
| 401 | - | } | |||
| 402 | - | // Full-width region: rotate line offsets instead of copying cells. | |||
| 403 | - | s.Frame.ScrollDown(top, bottom, n) | |||
| 404 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 408 | - | if s.Frame.Height == 0 || top > bottom { | |||
| 409 | - | return 0, 0, false | |||
| 410 | - | } | |||
| 411 | - | top = clamp(top, 0, s.Frame.Height-1) | |||
| 412 | - | bottom = clamp(bottom, 0, s.Frame.Height-1) | |||
| 413 | - | return top, bottom, top <= bottom | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | func (s *Screen) record(d renderer.Damage) { | |||
| 417 | - | s.damageGeneration++ | |||
| 418 | - | if s.damageSaturated || s.damageFullRedrawSticky { | |||
| 419 | - | return | |||
| 420 | - | } | |||
| 421 | - | if len(s.damage) >= maxPendingDamage { | |||
| 422 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 423 | - | s.damageSaturated = true | |||
| 424 | - | return | |||
| 425 | - | } | |||
| 426 | - | // Replace FullRedraw with the first concrete damage item. | |||
| 427 | - | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |||
| 428 | - | s.damage[0] = d | |||
| 429 | - | return | |||
| 430 | - | } | |||
| 431 | - | // Coalesce adjacent single-cell text damage on the same line. | |||
| 432 | - | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |||
| 433 | - | last := &s.damage[len(s.damage)-1] | |||
| 434 | - | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |||
| 435 | - | last.Width++ | |||
| 436 | - | return | |||
| 437 | - | } | |||
| 438 | - | } | |||
| 439 | - | s.damage = append(s.damage, d) | |||
| 440 | - | } | |||
| 441 | - | ||||
| 442 | - | func (s *Screen) fullRedraw() { | |||
| 443 | - | s.damageGeneration++ | |||
| 444 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 445 | - | // A structural frame replacement (such as DEC 1049 exit) cannot be | |||
| 446 | - | // represented by the following text damage alone. Retain the redraw until | |||
| 447 | - | // the render owner acknowledges this screen generation. | |||
| 448 | - | s.damageFullRedrawSticky = true | |||
| 449 | - | } | |||
| 450 | - | ||||
| 451 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | 144 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | ||
| 452 | if len(data) < 2 { | 145 | if len(data) < 2 { | ||
| 453 | return 0, true | 146 | return 0, true | ||
| @@ -506,196 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||||
| 506 | } | 199 | } | ||
| 507 | return 0, true | 200 | return 0, true | ||
| 508 | } | 201 | } | ||
| 509 | - | ||||
| 510 | - | func (s *Screen) reset() { | |||
| 511 | - | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 512 | - | s.Row, s.Col = 0, 0 | |||
| 513 | - | s.Style = renderer.DefaultStyle() | |||
| 514 | - | s.escapeBuf = s.escapeBuf[:0] | |||
| 515 | - | s.savedCursor = cursorState{} | |||
| 516 | - | s.alternate = nil | |||
| 517 | - | s.originMode = false | |||
| 518 | - | s.insertMode = false | |||
| 519 | - | s.cursorVisible = true | |||
| 520 | - | s.cursorStyle = 0 | |||
| 521 | - | s.cursorStyleSet = false | |||
| 522 | - | s.mouseMode = 0 | |||
| 523 | - | s.mouseSGR = false | |||
| 524 | - | s.bracketedPaste = false | |||
| 525 | - | s.colorSchemeMode = false | |||
| 526 | - | s.resetScrollRegion() | |||
| 527 | - | s.fullRedraw() | |||
| 528 | - | } | |||
| 529 | - | ||||
| 530 | - | func (s *Screen) setScrollRegion(parts []int) { | |||
| 531 | - | if s.Frame.Height == 0 { | |||
| 532 | - | return | |||
| 533 | - | } | |||
| 534 | - | top, bottom := 1, s.Frame.Height | |||
| 535 | - | if len(parts) > 0 && parts[0] > 0 { | |||
| 536 | - | top = parts[0] | |||
| 537 | - | } | |||
| 538 | - | if len(parts) > 1 && parts[1] > 0 { | |||
| 539 | - | bottom = parts[1] | |||
| 540 | - | } | |||
| 541 | - | if top >= bottom { | |||
| 542 | - | s.resetScrollRegion() | |||
| 543 | - | } else { | |||
| 544 | - | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |||
| 545 | - | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |||
| 546 | - | if s.scrollTop >= s.scrollBottom { | |||
| 547 | - | s.resetScrollRegion() | |||
| 548 | - | } | |||
| 549 | - | } | |||
| 550 | - | s.homeCursor() | |||
| 551 | - | } | |||
| 552 | - | ||||
| 553 | - | func (s *Screen) resetScrollRegion() { | |||
| 554 | - | s.scrollTop = 0 | |||
| 555 | - | if s.Frame.Height > 0 { | |||
| 556 | - | s.scrollBottom = s.Frame.Height - 1 | |||
| 557 | - | } else { | |||
| 558 | - | s.scrollBottom = 0 | |||
| 559 | - | } | |||
| 560 | - | } | |||
| 561 | - | ||||
| 562 | - | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 563 | - | if !private { | |||
| 564 | - | for _, mode := range parts { | |||
| 565 | - | switch mode { | |||
| 566 | - | case 4: | |||
| 567 | - | s.insertMode = enabled | |||
| 568 | - | default: | |||
| 569 | - | // Other ANSI modes (for example LNM) are intentionally ignored | |||
| 570 | - | // until the screen model implements their observable behavior. | |||
| 571 | - | continue | |||
| 572 | - | } | |||
| 573 | - | } | |||
| 574 | - | return | |||
| 575 | - | } | |||
| 576 | - | for _, mode := range parts { | |||
| 577 | - | switch mode { | |||
| 578 | - | case 6: | |||
| 579 | - | s.originMode = enabled | |||
| 580 | - | s.homeCursor() | |||
| 581 | - | case 47, 1047, 1049: | |||
| 582 | - | if enabled { | |||
| 583 | - | s.enterAlternateScreen() | |||
| 584 | - | } else { | |||
| 585 | - | s.exitAlternateScreen() | |||
| 586 | - | } | |||
| 587 | - | case 2026: | |||
| 588 | - | s.syncUpdateActive = enabled | |||
| 589 | - | case 2031: | |||
| 590 | - | s.colorSchemeMode = enabled | |||
| 591 | - | case 25: | |||
| 592 | - | s.cursorVisible = enabled | |||
| 593 | - | case 1000, 1002, 1003: | |||
| 594 | - | if enabled { | |||
| 595 | - | s.mouseMode = mode | |||
| 596 | - | } else if s.mouseMode == mode { | |||
| 597 | - | s.mouseMode = 0 | |||
| 598 | - | } | |||
| 599 | - | case 1006: | |||
| 600 | - | s.mouseSGR = enabled | |||
| 601 | - | case 2004: | |||
| 602 | - | s.bracketedPaste = enabled | |||
| 603 | - | case 1, 1004, 1005: | |||
| 604 | - | // Trackable terminal modes that do not directly affect the current | |||
| 605 | - | // cell model yet. Consuming them prevents mode bytes from leaking. | |||
| 606 | - | continue | |||
| 607 | - | } | |||
| 608 | - | } | |||
| 609 | - | } | |||
| 610 | - | ||||
| 611 | - | func (s *Screen) enterAlternateScreen() { | |||
| 612 | - | if s.alternate == nil { | |||
| 613 | - | s.alternate = &screenState{ | |||
| 614 | - | frame: cloneFrame(s.Frame), | |||
| 615 | - | row: s.Row, | |||
| 616 | - | col: s.Col, | |||
| 617 | - | style: s.Style, | |||
| 618 | - | scrollTop: s.scrollTop, | |||
| 619 | - | scrollBottom: s.scrollBottom, | |||
| 620 | - | savedCursor: s.savedCursor, | |||
| 621 | - | originMode: s.originMode, | |||
| 622 | - | insertMode: s.insertMode, | |||
| 623 | - | } | |||
| 624 | - | } | |||
| 625 | - | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 626 | - | s.Row, s.Col = 0, 0 | |||
| 627 | - | s.Style = renderer.DefaultStyle() | |||
| 628 | - | s.savedCursor = cursorState{} | |||
| 629 | - | s.resetScrollRegion() | |||
| 630 | - | s.fullRedraw() | |||
| 631 | - | } | |||
| 632 | - | ||||
| 633 | - | func (s *Screen) exitAlternateScreen() { | |||
| 634 | - | if s.alternate == nil { | |||
| 635 | - | return | |||
| 636 | - | } | |||
| 637 | - | state := s.alternate | |||
| 638 | - | s.Frame = cloneFrame(state.frame) | |||
| 639 | - | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |||
| 640 | - | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |||
| 641 | - | s.Style = state.style | |||
| 642 | - | s.scrollTop = state.scrollTop | |||
| 643 | - | s.scrollBottom = state.scrollBottom | |||
| 644 | - | s.savedCursor = state.savedCursor | |||
| 645 | - | s.originMode = state.originMode | |||
| 646 | - | s.insertMode = state.insertMode | |||
| 647 | - | s.alternate = nil | |||
| 648 | - | s.fullRedraw() | |||
| 649 | - | } | |||
| 650 | - | ||||
| 651 | - | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |||
| 652 | - | next := renderer.NewFrame(newW, newH) | |||
| 653 | - | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |||
| 654 | - | if evict != nil { | |||
| 655 | - | for y := range shift { | |||
| 656 | - | evict(old.Row(y)) | |||
| 657 | - | } | |||
| 658 | - | } | |||
| 659 | - | for dy := range newH { | |||
| 660 | - | sy := dy + shift | |||
| 661 | - | if sy >= old.Height { | |||
| 662 | - | break | |||
| 663 | - | } | |||
| 664 | - | copy(next.Row(dy), old.Row(sy)) | |||
| 665 | - | repairFrameRow(next, dy) | |||
| 666 | - | } | |||
| 667 | - | return next, shift | |||
| 668 | - | } | |||
| 669 | - | ||||
| 670 | - | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |||
| 671 | - | if !cur.saved { | |||
| 672 | - | return cur | |||
| 673 | - | } | |||
| 674 | - | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 675 | - | cur.col = clamp(cur.col, 0, width-1) | |||
| 676 | - | return cur | |||
| 677 | - | } | |||
| 678 | - | ||||
| 679 | - | // cloneFrame produces an independent copy in canonical layout: it copies the | |||
| 680 | - | // source's logical rows (via Row) into a fresh frame whose offsets are already | |||
| 681 | - | // canonical, so a rotated source is normalized in the clone. | |||
| 682 | - | func cloneFrame(frame renderer.Frame) renderer.Frame { | |||
| 683 | - | out := renderer.NewFrame(frame.Width, frame.Height) | |||
| 684 | - | for y := range frame.Height { | |||
| 685 | - | copy(out.Row(y), frame.Row(y)) | |||
| 686 | - | } | |||
| 687 | - | return out | |||
| 688 | - | } | |||
| 689 | - | ||||
| 690 | - | func clamp(v, lo, hi int) int { | |||
| 691 | - | if hi < lo { | |||
| 692 | - | return lo | |||
| 693 | - | } | |||
| 694 | - | if v < lo { | |||
| 695 | - | return lo | |||
| 696 | - | } | |||
| 697 | - | if v > hi { | |||
| 698 | - | return hi | |||
| 699 | - | } | |||
| 700 | - | return v | |||
| 701 | - | } | |||
A pkg/vt/screen_damage.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // maxPendingDamage bounds metadata retained while no render transaction can | |
| 6 | + | // acknowledge a screen. Saturation falls back to one exact full redraw. | |
| 7 | + | const maxPendingDamage = 1024 | |
| 8 | + | ||
| 9 | + | // DamageCapture is an immutable copy of pending damage at one screen generation. | |
| 10 | + | type DamageCapture struct { | |
| 11 | + | Damage []renderer.Damage | |
| 12 | + | Generation uint64 | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // Damage returns the current damage list. The caller must not modify the | |
| 16 | + | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 17 | + | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |
| 18 | + | func (s *Screen) ClearDamage() { | |
| 19 | + | s.damage = s.damage[:0] | |
| 20 | + | s.damageSaturated = false | |
| 21 | + | s.damageFullRedrawSticky = false | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // CaptureDamage snapshots pending damage without consuming it. | |
| 25 | + | func (s *Screen) CaptureDamage() DamageCapture { | |
| 26 | + | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |
| 30 | + | // since it was taken. A stale acknowledgement conservatively requests a full | |
| 31 | + | // redraw, ensuring intervening writes remain visible to the next capture. | |
| 32 | + | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |
| 33 | + | if generation != s.damageGeneration { | |
| 34 | + | s.fullRedraw() | |
| 35 | + | return false | |
| 36 | + | } | |
| 37 | + | s.ClearDamage() | |
| 38 | + | return true | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (s *Screen) record(d renderer.Damage) { | |
| 42 | + | s.damageGeneration++ | |
| 43 | + | if s.damageSaturated || s.damageFullRedrawSticky { | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | if len(s.damage) >= maxPendingDamage { | |
| 47 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 48 | + | s.damageSaturated = true | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | // Replace FullRedraw with the first concrete damage item. | |
| 52 | + | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 53 | + | s.damage[0] = d | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | // Coalesce adjacent single-cell text damage on the same line. | |
| 57 | + | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |
| 58 | + | last := &s.damage[len(s.damage)-1] | |
| 59 | + | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |
| 60 | + | last.Width++ | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | s.damage = append(s.damage, d) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (s *Screen) fullRedraw() { | |
| 68 | + | s.damageGeneration++ | |
| 69 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 70 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |
| 71 | + | // represented by the following text damage alone. Retain the redraw until | |
| 72 | + | // the render owner acknowledges this screen generation. | |
| 73 | + | s.damageFullRedrawSticky = true | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // maxPendingDamage bounds metadata retained while no render transaction can | |||
| 6 | + | // acknowledge a screen. Saturation falls back to one exact full redraw. | |||
| 7 | + | const maxPendingDamage = 1024 | |||
| 8 | + | ||||
| 9 | + | // DamageCapture is an immutable copy of pending damage at one screen generation. | |||
| 10 | + | type DamageCapture struct { | |||
| 11 | + | Damage []renderer.Damage | |||
| 12 | + | Generation uint64 | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // Damage returns the current damage list. The caller must not modify the | |||
| 16 | + | // returned slice; ClearDamage must be called after the damage is consumed. | |||
| 17 | + | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 18 | + | func (s *Screen) ClearDamage() { | |||
| 19 | + | s.damage = s.damage[:0] | |||
| 20 | + | s.damageSaturated = false | |||
| 21 | + | s.damageFullRedrawSticky = false | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // CaptureDamage snapshots pending damage without consuming it. | |||
| 25 | + | func (s *Screen) CaptureDamage() DamageCapture { | |||
| 26 | + | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |||
| 30 | + | // since it was taken. A stale acknowledgement conservatively requests a full | |||
| 31 | + | // redraw, ensuring intervening writes remain visible to the next capture. | |||
| 32 | + | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |||
| 33 | + | if generation != s.damageGeneration { | |||
| 34 | + | s.fullRedraw() | |||
| 35 | + | return false | |||
| 36 | + | } | |||
| 37 | + | s.ClearDamage() | |||
| 38 | + | return true | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (s *Screen) record(d renderer.Damage) { | |||
| 42 | + | s.damageGeneration++ | |||
| 43 | + | if s.damageSaturated || s.damageFullRedrawSticky { | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | if len(s.damage) >= maxPendingDamage { | |||
| 47 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 48 | + | s.damageSaturated = true | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | // Replace FullRedraw with the first concrete damage item. | |||
| 52 | + | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |||
| 53 | + | s.damage[0] = d | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | // Coalesce adjacent single-cell text damage on the same line. | |||
| 57 | + | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |||
| 58 | + | last := &s.damage[len(s.damage)-1] | |||
| 59 | + | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |||
| 60 | + | last.Width++ | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | s.damage = append(s.damage, d) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (s *Screen) fullRedraw() { | |||
| 68 | + | s.damageGeneration++ | |||
| 69 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 70 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |||
| 71 | + | // represented by the following text damage alone. Retain the redraw until | |||
| 72 | + | // the render owner acknowledges this screen generation. | |||
| 73 | + | s.damageFullRedrawSticky = true | |||
| 74 | + | } | |||
A pkg/vt/screen_modes.go +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | type screenState struct { | |
| 6 | + | frame renderer.Frame | |
| 7 | + | row int | |
| 8 | + | col int | |
| 9 | + | style renderer.Style | |
| 10 | + | scrollTop int | |
| 11 | + | scrollBottom int | |
| 12 | + | savedCursor cursorState | |
| 13 | + | originMode bool | |
| 14 | + | insertMode bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| 18 | + | // is currently enabled by the child process. | |
| 19 | + | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |
| 20 | + | ||
| 21 | + | // BracketedPasteMode reports whether DEC private mode 2004 is currently enabled | |
| 22 | + | // by the child process. | |
| 23 | + | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |
| 24 | + | ||
| 25 | + | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |
| 26 | + | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |
| 27 | + | ||
| 28 | + | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |
| 29 | + | func (s *Screen) SetColorScheme(light bool) { | |
| 30 | + | if s.colorSchemeSet && s.colorSchemeLight == light { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | s.colorSchemeSet = true | |
| 34 | + | s.colorSchemeLight = light | |
| 35 | + | if s.colorSchemeMode { | |
| 36 | + | s.respond(s.colorSchemeReport()) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // ClearColorScheme marks the host color scheme as unknown. Future child color | |
| 41 | + | // scheme queries are silent until SetColorScheme supplies a known value again. | |
| 42 | + | func (s *Screen) ClearColorScheme() { | |
| 43 | + | s.colorSchemeSet = false | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |
| 47 | + | // Hosts use this as a safety valve if a child enters synchronized update mode | |
| 48 | + | // and never sends the matching end sequence. | |
| 49 | + | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |
| 50 | + | ||
| 51 | + | // SetDefaultColors sets the terminal default foreground/background colors used | |
| 52 | + | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |
| 53 | + | // silent until known colors are supplied again. | |
| 54 | + | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |
| 55 | + | s.defaultFG = fg | |
| 56 | + | s.defaultBG = bg | |
| 57 | + | s.defaultColorsKnown = ok | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (s *Screen) CursorRow() int { return s.Row } | |
| 61 | + | func (s *Screen) CursorCol() int { return s.Col } | |
| 62 | + | func (s *Screen) CursorVisible() bool { | |
| 63 | + | return s.cursorVisible | |
| 64 | + | } | |
| 65 | + | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |
| 66 | + | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |
| 67 | + | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |
| 68 | + | ||
| 69 | + | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |
| 70 | + | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |
| 71 | + | ||
| 72 | + | func (s *Screen) respond(b []byte) { | |
| 73 | + | if s.OnResponse != nil { | |
| 74 | + | s.OnResponse(b) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (s *Screen) colorSchemeReport() []byte { | |
| 79 | + | if !s.colorSchemeSet { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | if s.colorSchemeLight { | |
| 83 | + | return []byte(ColorSchemeReportLight) | |
| 84 | + | } | |
| 85 | + | return []byte(ColorSchemeReportDark) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (s *Screen) reset() { | |
| 89 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 90 | + | s.Row, s.Col = 0, 0 | |
| 91 | + | s.Style = renderer.DefaultStyle() | |
| 92 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 93 | + | s.savedCursor = cursorState{} | |
| 94 | + | s.alternate = nil | |
| 95 | + | s.originMode = false | |
| 96 | + | s.insertMode = false | |
| 97 | + | s.cursorVisible = true | |
| 98 | + | s.cursorStyle = 0 | |
| 99 | + | s.cursorStyleSet = false | |
| 100 | + | s.mouseMode = 0 | |
| 101 | + | s.mouseSGR = false | |
| 102 | + | s.bracketedPaste = false | |
| 103 | + | s.colorSchemeMode = false | |
| 104 | + | s.resetScrollRegion() | |
| 105 | + | s.fullRedraw() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (s *Screen) setScrollRegion(parts []int) { | |
| 109 | + | if s.Frame.Height == 0 { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | top, bottom := 1, s.Frame.Height | |
| 113 | + | if len(parts) > 0 && parts[0] > 0 { | |
| 114 | + | top = parts[0] | |
| 115 | + | } | |
| 116 | + | if len(parts) > 1 && parts[1] > 0 { | |
| 117 | + | bottom = parts[1] | |
| 118 | + | } | |
| 119 | + | if top >= bottom { | |
| 120 | + | s.resetScrollRegion() | |
| 121 | + | } else { | |
| 122 | + | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |
| 123 | + | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |
| 124 | + | if s.scrollTop >= s.scrollBottom { | |
| 125 | + | s.resetScrollRegion() | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | s.homeCursor() | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (s *Screen) resetScrollRegion() { | |
| 132 | + | s.scrollTop = 0 | |
| 133 | + | if s.Frame.Height > 0 { | |
| 134 | + | s.scrollBottom = s.Frame.Height - 1 | |
| 135 | + | } else { | |
| 136 | + | s.scrollBottom = 0 | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |
| 141 | + | if !private { | |
| 142 | + | for _, mode := range parts { | |
| 143 | + | switch mode { | |
| 144 | + | case 4: | |
| 145 | + | s.insertMode = enabled | |
| 146 | + | default: | |
| 147 | + | // Other ANSI modes (for example LNM) are intentionally ignored | |
| 148 | + | // until the screen model implements their observable behavior. | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | for _, mode := range parts { | |
| 155 | + | switch mode { | |
| 156 | + | case 6: | |
| 157 | + | s.originMode = enabled | |
| 158 | + | s.homeCursor() | |
| 159 | + | case 47, 1047, 1049: | |
| 160 | + | if enabled { | |
| 161 | + | s.enterAlternateScreen() | |
| 162 | + | } else { | |
| 163 | + | s.exitAlternateScreen() | |
| 164 | + | } | |
| 165 | + | case 2026: | |
| 166 | + | s.syncUpdateActive = enabled | |
| 167 | + | case 2031: | |
| 168 | + | s.colorSchemeMode = enabled | |
| 169 | + | case 25: | |
| 170 | + | s.cursorVisible = enabled | |
| 171 | + | case 1000, 1002, 1003: | |
| 172 | + | if enabled { | |
| 173 | + | s.mouseMode = mode | |
| 174 | + | } else if s.mouseMode == mode { | |
| 175 | + | s.mouseMode = 0 | |
| 176 | + | } | |
| 177 | + | case 1006: | |
| 178 | + | s.mouseSGR = enabled | |
| 179 | + | case 2004: | |
| 180 | + | s.bracketedPaste = enabled | |
| 181 | + | case 1, 1004, 1005: | |
| 182 | + | // Trackable terminal modes that do not directly affect the current | |
| 183 | + | // cell model yet. Consuming them prevents mode bytes from leaking. | |
| 184 | + | continue | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (s *Screen) enterAlternateScreen() { | |
| 190 | + | if s.alternate == nil { | |
| 191 | + | s.alternate = &screenState{ | |
| 192 | + | frame: cloneFrame(s.Frame), | |
| 193 | + | row: s.Row, | |
| 194 | + | col: s.Col, | |
| 195 | + | style: s.Style, | |
| 196 | + | scrollTop: s.scrollTop, | |
| 197 | + | scrollBottom: s.scrollBottom, | |
| 198 | + | savedCursor: s.savedCursor, | |
| 199 | + | originMode: s.originMode, | |
| 200 | + | insertMode: s.insertMode, | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 204 | + | s.Row, s.Col = 0, 0 | |
| 205 | + | s.Style = renderer.DefaultStyle() | |
| 206 | + | s.savedCursor = cursorState{} | |
| 207 | + | s.resetScrollRegion() | |
| 208 | + | s.fullRedraw() | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (s *Screen) exitAlternateScreen() { | |
| 212 | + | if s.alternate == nil { | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | state := s.alternate | |
| 216 | + | s.Frame = cloneFrame(state.frame) | |
| 217 | + | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| 218 | + | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |
| 219 | + | s.Style = state.style | |
| 220 | + | s.scrollTop = state.scrollTop | |
| 221 | + | s.scrollBottom = state.scrollBottom | |
| 222 | + | s.savedCursor = state.savedCursor | |
| 223 | + | s.originMode = state.originMode | |
| 224 | + | s.insertMode = state.insertMode | |
| 225 | + | s.alternate = nil | |
| 226 | + | s.fullRedraw() | |
| 227 | + | } | |
| @@ -0,0 +1,227 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | type screenState struct { | |||
| 6 | + | frame renderer.Frame | |||
| 7 | + | row int | |||
| 8 | + | col int | |||
| 9 | + | style renderer.Style | |||
| 10 | + | scrollTop int | |||
| 11 | + | scrollBottom int | |||
| 12 | + | savedCursor cursorState | |||
| 13 | + | originMode bool | |||
| 14 | + | insertMode bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |||
| 18 | + | // is currently enabled by the child process. | |||
| 19 | + | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 20 | + | ||||
| 21 | + | // BracketedPasteMode reports whether DEC private mode 2004 is currently enabled | |||
| 22 | + | // by the child process. | |||
| 23 | + | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |||
| 24 | + | ||||
| 25 | + | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |||
| 26 | + | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |||
| 27 | + | ||||
| 28 | + | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |||
| 29 | + | func (s *Screen) SetColorScheme(light bool) { | |||
| 30 | + | if s.colorSchemeSet && s.colorSchemeLight == light { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | s.colorSchemeSet = true | |||
| 34 | + | s.colorSchemeLight = light | |||
| 35 | + | if s.colorSchemeMode { | |||
| 36 | + | s.respond(s.colorSchemeReport()) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // ClearColorScheme marks the host color scheme as unknown. Future child color | |||
| 41 | + | // scheme queries are silent until SetColorScheme supplies a known value again. | |||
| 42 | + | func (s *Screen) ClearColorScheme() { | |||
| 43 | + | s.colorSchemeSet = false | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |||
| 47 | + | // Hosts use this as a safety valve if a child enters synchronized update mode | |||
| 48 | + | // and never sends the matching end sequence. | |||
| 49 | + | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |||
| 50 | + | ||||
| 51 | + | // SetDefaultColors sets the terminal default foreground/background colors used | |||
| 52 | + | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |||
| 53 | + | // silent until known colors are supplied again. | |||
| 54 | + | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |||
| 55 | + | s.defaultFG = fg | |||
| 56 | + | s.defaultBG = bg | |||
| 57 | + | s.defaultColorsKnown = ok | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (s *Screen) CursorRow() int { return s.Row } | |||
| 61 | + | func (s *Screen) CursorCol() int { return s.Col } | |||
| 62 | + | func (s *Screen) CursorVisible() bool { | |||
| 63 | + | return s.cursorVisible | |||
| 64 | + | } | |||
| 65 | + | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |||
| 66 | + | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |||
| 67 | + | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |||
| 68 | + | ||||
| 69 | + | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |||
| 70 | + | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |||
| 71 | + | ||||
| 72 | + | func (s *Screen) respond(b []byte) { | |||
| 73 | + | if s.OnResponse != nil { | |||
| 74 | + | s.OnResponse(b) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (s *Screen) colorSchemeReport() []byte { | |||
| 79 | + | if !s.colorSchemeSet { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | if s.colorSchemeLight { | |||
| 83 | + | return []byte(ColorSchemeReportLight) | |||
| 84 | + | } | |||
| 85 | + | return []byte(ColorSchemeReportDark) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (s *Screen) reset() { | |||
| 89 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 90 | + | s.Row, s.Col = 0, 0 | |||
| 91 | + | s.Style = renderer.DefaultStyle() | |||
| 92 | + | s.escapeBuf = s.escapeBuf[:0] | |||
| 93 | + | s.savedCursor = cursorState{} | |||
| 94 | + | s.alternate = nil | |||
| 95 | + | s.originMode = false | |||
| 96 | + | s.insertMode = false | |||
| 97 | + | s.cursorVisible = true | |||
| 98 | + | s.cursorStyle = 0 | |||
| 99 | + | s.cursorStyleSet = false | |||
| 100 | + | s.mouseMode = 0 | |||
| 101 | + | s.mouseSGR = false | |||
| 102 | + | s.bracketedPaste = false | |||
| 103 | + | s.colorSchemeMode = false | |||
| 104 | + | s.resetScrollRegion() | |||
| 105 | + | s.fullRedraw() | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (s *Screen) setScrollRegion(parts []int) { | |||
| 109 | + | if s.Frame.Height == 0 { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | top, bottom := 1, s.Frame.Height | |||
| 113 | + | if len(parts) > 0 && parts[0] > 0 { | |||
| 114 | + | top = parts[0] | |||
| 115 | + | } | |||
| 116 | + | if len(parts) > 1 && parts[1] > 0 { | |||
| 117 | + | bottom = parts[1] | |||
| 118 | + | } | |||
| 119 | + | if top >= bottom { | |||
| 120 | + | s.resetScrollRegion() | |||
| 121 | + | } else { | |||
| 122 | + | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |||
| 123 | + | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |||
| 124 | + | if s.scrollTop >= s.scrollBottom { | |||
| 125 | + | s.resetScrollRegion() | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | s.homeCursor() | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (s *Screen) resetScrollRegion() { | |||
| 132 | + | s.scrollTop = 0 | |||
| 133 | + | if s.Frame.Height > 0 { | |||
| 134 | + | s.scrollBottom = s.Frame.Height - 1 | |||
| 135 | + | } else { | |||
| 136 | + | s.scrollBottom = 0 | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 141 | + | if !private { | |||
| 142 | + | for _, mode := range parts { | |||
| 143 | + | switch mode { | |||
| 144 | + | case 4: | |||
| 145 | + | s.insertMode = enabled | |||
| 146 | + | default: | |||
| 147 | + | // Other ANSI modes (for example LNM) are intentionally ignored | |||
| 148 | + | // until the screen model implements their observable behavior. | |||
| 149 | + | continue | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | for _, mode := range parts { | |||
| 155 | + | switch mode { | |||
| 156 | + | case 6: | |||
| 157 | + | s.originMode = enabled | |||
| 158 | + | s.homeCursor() | |||
| 159 | + | case 47, 1047, 1049: | |||
| 160 | + | if enabled { | |||
| 161 | + | s.enterAlternateScreen() | |||
| 162 | + | } else { | |||
| 163 | + | s.exitAlternateScreen() | |||
| 164 | + | } | |||
| 165 | + | case 2026: | |||
| 166 | + | s.syncUpdateActive = enabled | |||
| 167 | + | case 2031: | |||
| 168 | + | s.colorSchemeMode = enabled | |||
| 169 | + | case 25: | |||
| 170 | + | s.cursorVisible = enabled | |||
| 171 | + | case 1000, 1002, 1003: | |||
| 172 | + | if enabled { | |||
| 173 | + | s.mouseMode = mode | |||
| 174 | + | } else if s.mouseMode == mode { | |||
| 175 | + | s.mouseMode = 0 | |||
| 176 | + | } | |||
| 177 | + | case 1006: | |||
| 178 | + | s.mouseSGR = enabled | |||
| 179 | + | case 2004: | |||
| 180 | + | s.bracketedPaste = enabled | |||
| 181 | + | case 1, 1004, 1005: | |||
| 182 | + | // Trackable terminal modes that do not directly affect the current | |||
| 183 | + | // cell model yet. Consuming them prevents mode bytes from leaking. | |||
| 184 | + | continue | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (s *Screen) enterAlternateScreen() { | |||
| 190 | + | if s.alternate == nil { | |||
| 191 | + | s.alternate = &screenState{ | |||
| 192 | + | frame: cloneFrame(s.Frame), | |||
| 193 | + | row: s.Row, | |||
| 194 | + | col: s.Col, | |||
| 195 | + | style: s.Style, | |||
| 196 | + | scrollTop: s.scrollTop, | |||
| 197 | + | scrollBottom: s.scrollBottom, | |||
| 198 | + | savedCursor: s.savedCursor, | |||
| 199 | + | originMode: s.originMode, | |||
| 200 | + | insertMode: s.insertMode, | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 204 | + | s.Row, s.Col = 0, 0 | |||
| 205 | + | s.Style = renderer.DefaultStyle() | |||
| 206 | + | s.savedCursor = cursorState{} | |||
| 207 | + | s.resetScrollRegion() | |||
| 208 | + | s.fullRedraw() | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (s *Screen) exitAlternateScreen() { | |||
| 212 | + | if s.alternate == nil { | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | state := s.alternate | |||
| 216 | + | s.Frame = cloneFrame(state.frame) | |||
| 217 | + | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |||
| 218 | + | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |||
| 219 | + | s.Style = state.style | |||
| 220 | + | s.scrollTop = state.scrollTop | |||
| 221 | + | s.scrollBottom = state.scrollBottom | |||
| 222 | + | s.savedCursor = state.savedCursor | |||
| 223 | + | s.originMode = state.originMode | |||
| 224 | + | s.insertMode = state.insertMode | |||
| 225 | + | s.alternate = nil | |||
| 226 | + | s.fullRedraw() | |||
| 227 | + | } | |||
A pkg/vt/screen_resize.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | func (s *Screen) Resize(width, height int) { | |
| 6 | + | if width == s.Frame.Width && height == s.Frame.Height { | |
| 7 | + | return | |
| 8 | + | } | |
| 9 | + | evict := s.recordEvicted | |
| 10 | + | ||
| 11 | + | if s.alternate != nil { | |
| 12 | + | var shift int | |
| 13 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |
| 14 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 15 | + | s.Col = clamp(s.Col, 0, width-1) | |
| 16 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 17 | + | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |
| 18 | + | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |
| 19 | + | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |
| 20 | + | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |
| 21 | + | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |
| 22 | + | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |
| 23 | + | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |
| 24 | + | s.alternate.scrollTop = 0 | |
| 25 | + | s.alternate.scrollBottom = max(height-1, 0) | |
| 26 | + | } | |
| 27 | + | } else { | |
| 28 | + | var shift int | |
| 29 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |
| 30 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 31 | + | s.Col = clamp(s.Col, 0, width-1) | |
| 32 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // A resize can split an in-flight escape sequence from the terminal state it | |
| 36 | + | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 37 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 38 | + | s.resetScrollRegion() | |
| 39 | + | s.fullRedraw() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |
| 43 | + | next := renderer.NewFrame(newW, newH) | |
| 44 | + | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 45 | + | if evict != nil { | |
| 46 | + | for y := range shift { | |
| 47 | + | evict(old.Row(y)) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | for dy := range newH { | |
| 51 | + | sy := dy + shift | |
| 52 | + | if sy >= old.Height { | |
| 53 | + | break | |
| 54 | + | } | |
| 55 | + | copy(next.Row(dy), old.Row(sy)) | |
| 56 | + | repairFrameRow(next, dy) | |
| 57 | + | } | |
| 58 | + | return next, shift | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |
| 62 | + | if !cur.saved { | |
| 63 | + | return cur | |
| 64 | + | } | |
| 65 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 66 | + | cur.col = clamp(cur.col, 0, width-1) | |
| 67 | + | return cur | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 71 | + | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 72 | + | // canonical, so a rotated source is normalized in the clone. | |
| 73 | + | func cloneFrame(frame renderer.Frame) renderer.Frame { | |
| 74 | + | out := renderer.NewFrame(frame.Width, frame.Height) | |
| 75 | + | for y := range frame.Height { | |
| 76 | + | copy(out.Row(y), frame.Row(y)) | |
| 77 | + | } | |
| 78 | + | return out | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func clamp(v, lo, hi int) int { | |
| 82 | + | if hi < lo { | |
| 83 | + | return lo | |
| 84 | + | } | |
| 85 | + | if v < lo { | |
| 86 | + | return lo | |
| 87 | + | } | |
| 88 | + | if v > hi { | |
| 89 | + | return hi | |
| 90 | + | } | |
| 91 | + | return v | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | func (s *Screen) Resize(width, height int) { | |||
| 6 | + | if width == s.Frame.Width && height == s.Frame.Height { | |||
| 7 | + | return | |||
| 8 | + | } | |||
| 9 | + | evict := s.recordEvicted | |||
| 10 | + | ||||
| 11 | + | if s.alternate != nil { | |||
| 12 | + | var shift int | |||
| 13 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |||
| 14 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 15 | + | s.Col = clamp(s.Col, 0, width-1) | |||
| 16 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 17 | + | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |||
| 18 | + | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |||
| 19 | + | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |||
| 20 | + | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |||
| 21 | + | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |||
| 22 | + | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |||
| 23 | + | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |||
| 24 | + | s.alternate.scrollTop = 0 | |||
| 25 | + | s.alternate.scrollBottom = max(height-1, 0) | |||
| 26 | + | } | |||
| 27 | + | } else { | |||
| 28 | + | var shift int | |||
| 29 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |||
| 30 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 31 | + | s.Col = clamp(s.Col, 0, width-1) | |||
| 32 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // A resize can split an in-flight escape sequence from the terminal state it | |||
| 36 | + | // was meant to mutate; keep the durable child state but discard partial bytes. | |||
| 37 | + | s.escapeBuf = s.escapeBuf[:0] | |||
| 38 | + | s.resetScrollRegion() | |||
| 39 | + | s.fullRedraw() | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |||
| 43 | + | next := renderer.NewFrame(newW, newH) | |||
| 44 | + | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |||
| 45 | + | if evict != nil { | |||
| 46 | + | for y := range shift { | |||
| 47 | + | evict(old.Row(y)) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | for dy := range newH { | |||
| 51 | + | sy := dy + shift | |||
| 52 | + | if sy >= old.Height { | |||
| 53 | + | break | |||
| 54 | + | } | |||
| 55 | + | copy(next.Row(dy), old.Row(sy)) | |||
| 56 | + | repairFrameRow(next, dy) | |||
| 57 | + | } | |||
| 58 | + | return next, shift | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |||
| 62 | + | if !cur.saved { | |||
| 63 | + | return cur | |||
| 64 | + | } | |||
| 65 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 66 | + | cur.col = clamp(cur.col, 0, width-1) | |||
| 67 | + | return cur | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // cloneFrame produces an independent copy in canonical layout: it copies the | |||
| 71 | + | // source's logical rows (via Row) into a fresh frame whose offsets are already | |||
| 72 | + | // canonical, so a rotated source is normalized in the clone. | |||
| 73 | + | func cloneFrame(frame renderer.Frame) renderer.Frame { | |||
| 74 | + | out := renderer.NewFrame(frame.Width, frame.Height) | |||
| 75 | + | for y := range frame.Height { | |||
| 76 | + | copy(out.Row(y), frame.Row(y)) | |||
| 77 | + | } | |||
| 78 | + | return out | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func clamp(v, lo, hi int) int { | |||
| 82 | + | if hi < lo { | |||
| 83 | + | return lo | |||
| 84 | + | } | |||
| 85 | + | if v < lo { | |||
| 86 | + | return lo | |||
| 87 | + | } | |||
| 88 | + | if v > hi { | |||
| 89 | + | return hi | |||
| 90 | + | } | |||
| 91 | + | return v | |||
| 92 | + | } | |||
A pkg/vt/screen_scroll.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | func (s *Screen) index() { | |
| 6 | + | if s.Frame.Height == 0 { | |
| 7 | + | return | |
| 8 | + | } | |
| 9 | + | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |
| 10 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |
| 11 | + | return | |
| 12 | + | } | |
| 13 | + | if s.Row+1 < s.Frame.Height { | |
| 14 | + | s.Row++ | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | s.Row = s.Frame.Height - 1 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (s *Screen) nextLine() { | |
| 21 | + | s.Col = 0 | |
| 22 | + | s.index() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (s *Screen) reverseIndex() { | |
| 26 | + | if s.Frame.Height == 0 { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |
| 30 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | if s.Row > 0 { | |
| 34 | + | s.Row-- | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |
| 39 | + | // The cursor position is preserved. | |
| 40 | + | func (s *Screen) scrollUpBy(n int) { | |
| 41 | + | if n <= 0 { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (s *Screen) scrollDownBy(n int) { | |
| 48 | + | if n <= 0 { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |
| 55 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 59 | + | if !ok { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | w := s.Frame.Width | |
| 63 | + | height := bottom - top + 1 | |
| 64 | + | if n > height { | |
| 65 | + | n = height | |
| 66 | + | } | |
| 67 | + | // VT scroll regions always span the full frame width, so we rotate the | |
| 68 | + | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 69 | + | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 70 | + | s.emitLineEvicted(top, n) | |
| 71 | + | s.Frame.ScrollUp(top, bottom, n) | |
| 72 | + | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 73 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s *Screen) emitLineEvicted(top, n int) { | |
| 77 | + | if s.alternate != nil { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | for y := top; y < top+n; y++ { | |
| 81 | + | s.recordEvicted(s.Frame.Row(y)) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 86 | + | if s.history != nil { | |
| 87 | + | s.history.Append(row) | |
| 88 | + | } | |
| 89 | + | if s.OnLineEvicted != nil { | |
| 90 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 95 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 99 | + | if !ok { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | w := s.Frame.Width | |
| 103 | + | height := bottom - top + 1 | |
| 104 | + | if n > height { | |
| 105 | + | n = height | |
| 106 | + | } | |
| 107 | + | // Full-width region: rotate line offsets instead of copying cells. | |
| 108 | + | s.Frame.ScrollDown(top, bottom, n) | |
| 109 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |
| 113 | + | if s.Frame.Height == 0 || top > bottom { | |
| 114 | + | return 0, 0, false | |
| 115 | + | } | |
| 116 | + | top = clamp(top, 0, s.Frame.Height-1) | |
| 117 | + | bottom = clamp(bottom, 0, s.Frame.Height-1) | |
| 118 | + | return top, bottom, top <= bottom | |
| 119 | + | } | |
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | func (s *Screen) index() { | |||
| 6 | + | if s.Frame.Height == 0 { | |||
| 7 | + | return | |||
| 8 | + | } | |||
| 9 | + | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |||
| 10 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 11 | + | return | |||
| 12 | + | } | |||
| 13 | + | if s.Row+1 < s.Frame.Height { | |||
| 14 | + | s.Row++ | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | s.Row = s.Frame.Height - 1 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (s *Screen) nextLine() { | |||
| 21 | + | s.Col = 0 | |||
| 22 | + | s.index() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (s *Screen) reverseIndex() { | |||
| 26 | + | if s.Frame.Height == 0 { | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |||
| 30 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | if s.Row > 0 { | |||
| 34 | + | s.Row-- | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |||
| 39 | + | // The cursor position is preserved. | |||
| 40 | + | func (s *Screen) scrollUpBy(n int) { | |||
| 41 | + | if n <= 0 { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (s *Screen) scrollDownBy(n int) { | |||
| 48 | + | if n <= 0 { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 55 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 59 | + | if !ok { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | w := s.Frame.Width | |||
| 63 | + | height := bottom - top + 1 | |||
| 64 | + | if n > height { | |||
| 65 | + | n = height | |||
| 66 | + | } | |||
| 67 | + | // VT scroll regions always span the full frame width, so we rotate the | |||
| 68 | + | // frame's line offsets (recycling and blanking the evicted rows in place) | |||
| 69 | + | // instead of copying cells. See renderer.Frame.ScrollUp. | |||
| 70 | + | s.emitLineEvicted(top, n) | |||
| 71 | + | s.Frame.ScrollUp(top, bottom, n) | |||
| 72 | + | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |||
| 73 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (s *Screen) emitLineEvicted(top, n int) { | |||
| 77 | + | if s.alternate != nil { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | for y := top; y < top+n; y++ { | |||
| 81 | + | s.recordEvicted(s.Frame.Row(y)) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (s *Screen) recordEvicted(row []renderer.Cell) { | |||
| 86 | + | if s.history != nil { | |||
| 87 | + | s.history.Append(row) | |||
| 88 | + | } | |||
| 89 | + | if s.OnLineEvicted != nil { | |||
| 90 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 95 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 99 | + | if !ok { | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | w := s.Frame.Width | |||
| 103 | + | height := bottom - top + 1 | |||
| 104 | + | if n > height { | |||
| 105 | + | n = height | |||
| 106 | + | } | |||
| 107 | + | // Full-width region: rotate line offsets instead of copying cells. | |||
| 108 | + | s.Frame.ScrollDown(top, bottom, n) | |||
| 109 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 113 | + | if s.Frame.Height == 0 || top > bottom { | |||
| 114 | + | return 0, 0, false | |||
| 115 | + | } | |||
| 116 | + | top = clamp(top, 0, s.Frame.Height-1) | |||
| 117 | + | bottom = clamp(bottom, 0, s.Frame.Height-1) | |||
| 118 | + | return top, bottom, top <= bottom | |||
| 119 | + | } | |||