fix(theme): keep escape sequences contiguous and guard detach color reset
- commit
- 8e0f50ec3768aef8f923364dc71e2cc76121d037
- author
- brice · · 2026-07-03 14:40 UTC
- committer
- brice · · 2026-07-03 14:43 UTC
- parent
- ef01342
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +290 -25
M internal/usecase/client/client_test.go +59 -0
| @@ -412,6 +412,65 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||
| 412 | 412 | } | |
| 413 | 413 | } | |
| 414 | 414 | ||
| 415 | + | func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |
| 416 | + | var out bytes.Buffer | |
| 417 | + | var restoreCount atomic.Int32 | |
| 418 | + | resizeCh := make(chan domain.Size) | |
| 419 | + | input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M")) | |
| 420 | + | defer input.unblock() | |
| 421 | + | ||
| 422 | + | tm := portsmocks.NewMockTerminal(t) | |
| 423 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 424 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 425 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 426 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 427 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 428 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 429 | + | ||
| 430 | + | gotInput := make(chan []byte, 2) | |
| 431 | + | allowDetach := make(chan struct{}) | |
| 432 | + | tr := portsmocks.NewMockTransport(t) | |
| 433 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 434 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 435 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | gotInput <- in.Data | |
| 438 | + | close(allowDetach) | |
| 439 | + | return nil | |
| 440 | + | }).Once() | |
| 441 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 442 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 443 | + | recvCh := make(chan recvItem, 1) | |
| 444 | + | recvCh <- recvItem{f: welcome} | |
| 445 | + | closed := make(chan struct{}) | |
| 446 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 447 | + | select { | |
| 448 | + | case it := <-recvCh: | |
| 449 | + | return it.f, it.err | |
| 450 | + | case <-allowDetach: | |
| 451 | + | select { | |
| 452 | + | case <-closed: | |
| 453 | + | return ports.Frame{}, io.EOF | |
| 454 | + | default: | |
| 455 | + | close(closed) | |
| 456 | + | return detached, nil | |
| 457 | + | } | |
| 458 | + | case <-closed: | |
| 459 | + | return ports.Frame{}, io.EOF | |
| 460 | + | } | |
| 461 | + | }).Maybe() | |
| 462 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 463 | + | ||
| 464 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 465 | + | require.NoError(t, err) | |
| 466 | + | select { | |
| 467 | + | case got := <-gotInput: | |
| 468 | + | require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame") | |
| 469 | + | case <-time.After(2 * time.Second): | |
| 470 | + | t.Fatal("mouse report input was not sent") | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 415 | 474 | func TestAttachForwardsResize(t *testing.T) { | |
| 416 | 475 | var out bytes.Buffer | |
| 417 | 476 | var restoreCount atomic.Int32 | |
| @@ -412,6 +412,65 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||||
| 412 | } | 412 | } | ||
| 413 | } | 413 | } | ||
| 414 | 414 | ||||
| 415 | + | func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 416 | + | var out bytes.Buffer | |||
| 417 | + | var restoreCount atomic.Int32 | |||
| 418 | + | resizeCh := make(chan domain.Size) | |||
| 419 | + | input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M")) | |||
| 420 | + | defer input.unblock() | |||
| 421 | + | ||||
| 422 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 423 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 424 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 425 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 426 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 427 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 428 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 429 | + | ||||
| 430 | + | gotInput := make(chan []byte, 2) | |||
| 431 | + | allowDetach := make(chan struct{}) | |||
| 432 | + | tr := portsmocks.NewMockTransport(t) | |||
| 433 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 434 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 435 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 436 | + | require.NoError(t, err) | |||
| 437 | + | gotInput <- in.Data | |||
| 438 | + | close(allowDetach) | |||
| 439 | + | return nil | |||
| 440 | + | }).Once() | |||
| 441 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 442 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 443 | + | recvCh := make(chan recvItem, 1) | |||
| 444 | + | recvCh <- recvItem{f: welcome} | |||
| 445 | + | closed := make(chan struct{}) | |||
| 446 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 447 | + | select { | |||
| 448 | + | case it := <-recvCh: | |||
| 449 | + | return it.f, it.err | |||
| 450 | + | case <-allowDetach: | |||
| 451 | + | select { | |||
| 452 | + | case <-closed: | |||
| 453 | + | return ports.Frame{}, io.EOF | |||
| 454 | + | default: | |||
| 455 | + | close(closed) | |||
| 456 | + | return detached, nil | |||
| 457 | + | } | |||
| 458 | + | case <-closed: | |||
| 459 | + | return ports.Frame{}, io.EOF | |||
| 460 | + | } | |||
| 461 | + | }).Maybe() | |||
| 462 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 463 | + | ||||
| 464 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |||
| 465 | + | require.NoError(t, err) | |||
| 466 | + | select { | |||
| 467 | + | case got := <-gotInput: | |||
| 468 | + | require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame") | |||
| 469 | + | case <-time.After(2 * time.Second): | |||
| 470 | + | t.Fatal("mouse report input was not sent") | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | ||||
| 415 | func TestAttachForwardsResize(t *testing.T) { | 474 | func TestAttachForwardsResize(t *testing.T) { | ||
| 416 | var out bytes.Buffer | 475 | var out bytes.Buffer | ||
| 417 | var restoreCount atomic.Int32 | 476 | var restoreCount atomic.Int32 | ||
M internal/usecase/copy/mode.go +1 -1
| @@ -180,7 +180,7 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |||
| 180 | 180 | ||
| 181 | 181 | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | |
| 182 | 182 | if !ok || selection.Equal(inverseStyle()) { | |
| 183 | - | dst.Inverse = !dst.Inverse | |
| 183 | + | dst.Inverse = true | |
| 184 | 184 | return | |
| 185 | 185 | } | |
| 186 | 186 | *dst = selection | |
| @@ -180,7 +180,7 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |||||
| 180 | 180 | ||||
| 181 | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | 181 | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | ||
| 182 | if !ok || selection.Equal(inverseStyle()) { | 182 | if !ok || selection.Equal(inverseStyle()) { | ||
| 183 | - | dst.Inverse = !dst.Inverse | 183 | + | dst.Inverse = true |
| 184 | return | 184 | return | ||
| 185 | } | 185 | } | ||
| 186 | *dst = selection | 186 | *dst = selection | ||
M internal/usecase/copy/mode_test.go +17 -0
| @@ -265,6 +265,23 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) { | |||
| 265 | 265 | } | |
| 266 | 266 | } | |
| 267 | 267 | ||
| 268 | + | func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |
| 269 | + | // A cell that was already rendered inverse by the child program (e.g. its | |
| 270 | + | // own reverse-video attribute) must stay visibly selected: the fallback | |
| 271 | + | // selection style is a solid "force inverse", not an XOR toggle that would | |
| 272 | + | // flip it back to non-inverse and hide the highlight. | |
| 273 | + | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | |
| 274 | + | rows[0][0].Style.Inverse = true | |
| 275 | + | s := Snapshot{Rows: rows, Width: 16, Height: 2} | |
| 276 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 277 | + | ||
| 278 | + | frame := m.Render(s) | |
| 279 | + | ||
| 280 | + | if !frame.At(0, 0).Style.Inverse { | |
| 281 | + | t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true") | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 268 | 285 | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | |
| 269 | 286 | s := snapshot([]string{"alpha ", "beta "}, 2) | |
| 270 | 287 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| @@ -265,6 +265,23 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) { | |||||
| 265 | } | 265 | } | ||
| 266 | } | 266 | } | ||
| 267 | 267 | ||||
| 268 | + | func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |||
| 269 | + | // A cell that was already rendered inverse by the child program (e.g. its | |||
| 270 | + | // own reverse-video attribute) must stay visibly selected: the fallback | |||
| 271 | + | // selection style is a solid "force inverse", not an XOR toggle that would | |||
| 272 | + | // flip it back to non-inverse and hide the highlight. | |||
| 273 | + | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | |||
| 274 | + | rows[0][0].Style.Inverse = true | |||
| 275 | + | s := Snapshot{Rows: rows, Width: 16, Height: 2} | |||
| 276 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |||
| 277 | + | ||||
| 278 | + | frame := m.Render(s) | |||
| 279 | + | ||||
| 280 | + | if !frame.At(0, 0).Style.Inverse { | |||
| 281 | + | t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true") | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 268 | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | 285 | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | ||
| 269 | s := snapshot([]string{"alpha ", "beta "}, 2) | 286 | s := snapshot([]string{"alpha ", "beta "}, 2) | ||
| 270 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | 287 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | ||
M internal/usecase/daemon/client.go +28 -0
| @@ -259,6 +259,32 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 259 | 259 | return ac, old | |
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | + | // resetScreenDefaultColors clears the known default foreground/background | |
| 263 | + | // colors on every tab in sess. Called once a client has been detached, this | |
| 264 | + | // makes child OSC 10/11 queries go back to being swallowed (Known=false) | |
| 265 | + | // instead of being answered with the departed client's colors, which the | |
| 266 | + | // next client (with a different terminal theme) may never have reported. | |
| 267 | + | // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu, | |
| 268 | + | // release it, then take each tb.mu in turn — never holding sess.mu and | |
| 269 | + | // tb.mu together. Guarded against a race with a newer attach: if sess.client | |
| 270 | + | // is non-nil by the time sess.mu is taken, a new client has already attached | |
| 271 | + | // (and run its own attach-time reset), so this call must leave the tabs | |
| 272 | + | // alone rather than clobbering that client's freshly applied colors. | |
| 273 | + | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 274 | + | sess.mu.Lock() | |
| 275 | + | if sess.client != nil { | |
| 276 | + | sess.mu.Unlock() | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 280 | + | sess.mu.Unlock() | |
| 281 | + | for _, tb := range tabs { | |
| 282 | + | tb.mu.Lock() | |
| 283 | + | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 284 | + | tb.mu.Unlock() | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 262 | 288 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| 263 | 289 | if old == nil { | |
| 264 | 290 | return | |
| @@ -339,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 339 | 365 | return // already displaced by a newer client; nothing to do | |
| 340 | 366 | } | |
| 341 | 367 | d.unregisterPreview(ac) | |
| 368 | + | d.resetScreenDefaultColors(sess) | |
| 342 | 369 | if explicit { | |
| 343 | 370 | // Synchronous so the ack is delivered before the transport closes | |
| 344 | 371 | // (the client is actively awaiting it), but deadline-bounded so a | |
| @@ -414,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 414 | 441 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |
| 415 | 442 | if sess.detachIfCurrent(ac) { | |
| 416 | 443 | d.unregisterPreview(ac) | |
| 444 | + | d.resetScreenDefaultColors(sess) | |
| 417 | 445 | _ = ac.tr.Close() | |
| 418 | 446 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 419 | 447 | if sess.ephemeral { | |
| @@ -259,6 +259,32 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 259 | return ac, old | 259 | return ac, old | ||
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | + | // resetScreenDefaultColors clears the known default foreground/background | |||
| 263 | + | // colors on every tab in sess. Called once a client has been detached, this | |||
| 264 | + | // makes child OSC 10/11 queries go back to being swallowed (Known=false) | |||
| 265 | + | // instead of being answered with the departed client's colors, which the | |||
| 266 | + | // next client (with a different terminal theme) may never have reported. | |||
| 267 | + | // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu, | |||
| 268 | + | // release it, then take each tb.mu in turn — never holding sess.mu and | |||
| 269 | + | // tb.mu together. Guarded against a race with a newer attach: if sess.client | |||
| 270 | + | // is non-nil by the time sess.mu is taken, a new client has already attached | |||
| 271 | + | // (and run its own attach-time reset), so this call must leave the tabs | |||
| 272 | + | // alone rather than clobbering that client's freshly applied colors. | |||
| 273 | + | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 274 | + | sess.mu.Lock() | |||
| 275 | + | if sess.client != nil { | |||
| 276 | + | sess.mu.Unlock() | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 280 | + | sess.mu.Unlock() | |||
| 281 | + | for _, tb := range tabs { | |||
| 282 | + | tb.mu.Lock() | |||
| 283 | + | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 284 | + | tb.mu.Unlock() | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 262 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 288 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | ||
| 263 | if old == nil { | 289 | if old == nil { | ||
| 264 | return | 290 | return | ||
| @@ -339,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 339 | return // already displaced by a newer client; nothing to do | 365 | return // already displaced by a newer client; nothing to do | ||
| 340 | } | 366 | } | ||
| 341 | d.unregisterPreview(ac) | 367 | d.unregisterPreview(ac) | ||
| 368 | + | d.resetScreenDefaultColors(sess) | |||
| 342 | if explicit { | 369 | if explicit { | ||
| 343 | // Synchronous so the ack is delivered before the transport closes | 370 | // Synchronous so the ack is delivered before the transport closes | ||
| 344 | // (the client is actively awaiting it), but deadline-bounded so a | 371 | // (the client is actively awaiting it), but deadline-bounded so a | ||
| @@ -414,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 414 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | 441 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | ||
| 415 | if sess.detachIfCurrent(ac) { | 442 | if sess.detachIfCurrent(ac) { | ||
| 416 | d.unregisterPreview(ac) | 443 | d.unregisterPreview(ac) | ||
| 444 | + | d.resetScreenDefaultColors(sess) | |||
| 417 | _ = ac.tr.Close() | 445 | _ = ac.tr.Close() | ||
| 418 | d.log.Warn("detached client after send error", "session", sess.name) | 446 | d.log.Warn("detached client after send error", "session", sess.name) | ||
| 419 | if sess.ephemeral { | 447 | if sess.ephemeral { | ||
M internal/usecase/daemon/status_test.go +75 -0
| @@ -110,6 +110,81 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||
| 110 | 110 | require.Empty(t, got) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | func TestClientGoneResetsScreenDefaultColors(t *testing.T) { | |
| 114 | + | p, release := newBlockingPTY(t) | |
| 115 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 116 | + | defer release() | |
| 117 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 118 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 119 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 120 | + | }) | |
| 121 | + | ||
| 122 | + | // Sanity: the tab answers OSC 10/11 while ac is attached. | |
| 123 | + | tb := sess.activeTab() | |
| 124 | + | var before []byte | |
| 125 | + | tb.screen.OnResponse = func(b []byte) { before = append(before, b...) } | |
| 126 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 127 | + | require.NotEmpty(t, before) | |
| 128 | + | ||
| 129 | + | d.clientGone(sess, ac, true) | |
| 130 | + | ||
| 131 | + | var got []byte | |
| 132 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 133 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 134 | + | require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |
| 138 | + | p, release := newBlockingPTY(t) | |
| 139 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 140 | + | defer release() | |
| 141 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 142 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 143 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 144 | + | }) | |
| 145 | + | ||
| 146 | + | // Simulate the race window inside clientGone/detachOnSendError: the old | |
| 147 | + | // client has already been detached (detachIfCurrent succeeded), but | |
| 148 | + | // before resetScreenDefaultColors runs a new client attaches and applies | |
| 149 | + | // its own theme. | |
| 150 | + | require.True(t, sess.detachIfCurrent(ac)) | |
| 151 | + | ||
| 152 | + | tr, _ := newCapturingTransport(t) | |
| 153 | + | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 154 | + | d.applyTheme(sess, newAC, ports.Theme{ | |
| 155 | + | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 156 | + | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | // The late reset from the old client's detach path must not wipe the | |
| 160 | + | // new client's freshly applied colors. | |
| 161 | + | d.resetScreenDefaultColors(sess) | |
| 162 | + | ||
| 163 | + | tb := sess.activeTab() | |
| 164 | + | var got []byte | |
| 165 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 166 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 167 | + | require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) { | |
| 171 | + | p, release := newBlockingPTY(t) | |
| 172 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 173 | + | defer release() | |
| 174 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 175 | + | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 176 | + | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | |
| 177 | + | }) | |
| 178 | + | ||
| 179 | + | d.detachOnSendError(sess, ac) | |
| 180 | + | ||
| 181 | + | tb := sess.activeTab() | |
| 182 | + | var got []byte | |
| 183 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 184 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 185 | + | require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone") | |
| 186 | + | } | |
| 187 | + | ||
| 113 | 188 | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |
| 114 | 189 | p, release := newBlockingPTY(t) | |
| 115 | 190 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| @@ -110,6 +110,81 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||||
| 110 | require.Empty(t, got) | 110 | require.Empty(t, got) | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | + | func TestClientGoneResetsScreenDefaultColors(t *testing.T) { | |||
| 114 | + | p, release := newBlockingPTY(t) | |||
| 115 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 116 | + | defer release() | |||
| 117 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 118 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 119 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | // Sanity: the tab answers OSC 10/11 while ac is attached. | |||
| 123 | + | tb := sess.activeTab() | |||
| 124 | + | var before []byte | |||
| 125 | + | tb.screen.OnResponse = func(b []byte) { before = append(before, b...) } | |||
| 126 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 127 | + | require.NotEmpty(t, before) | |||
| 128 | + | ||||
| 129 | + | d.clientGone(sess, ac, true) | |||
| 130 | + | ||||
| 131 | + | var got []byte | |||
| 132 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 133 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 134 | + | require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone") | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||
| 138 | + | p, release := newBlockingPTY(t) | |||
| 139 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 140 | + | defer release() | |||
| 141 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 142 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 143 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 144 | + | }) | |||
| 145 | + | ||||
| 146 | + | // Simulate the race window inside clientGone/detachOnSendError: the old | |||
| 147 | + | // client has already been detached (detachIfCurrent succeeded), but | |||
| 148 | + | // before resetScreenDefaultColors runs a new client attaches and applies | |||
| 149 | + | // its own theme. | |||
| 150 | + | require.True(t, sess.detachIfCurrent(ac)) | |||
| 151 | + | ||||
| 152 | + | tr, _ := newCapturingTransport(t) | |||
| 153 | + | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |||
| 154 | + | d.applyTheme(sess, newAC, ports.Theme{ | |||
| 155 | + | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |||
| 156 | + | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | // The late reset from the old client's detach path must not wipe the | |||
| 160 | + | // new client's freshly applied colors. | |||
| 161 | + | d.resetScreenDefaultColors(sess) | |||
| 162 | + | ||||
| 163 | + | tb := sess.activeTab() | |||
| 164 | + | var got []byte | |||
| 165 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 166 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 167 | + | require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset") | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) { | |||
| 171 | + | p, release := newBlockingPTY(t) | |||
| 172 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 173 | + | defer release() | |||
| 174 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 175 | + | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 176 | + | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | |||
| 177 | + | }) | |||
| 178 | + | ||||
| 179 | + | d.detachOnSendError(sess, ac) | |||
| 180 | + | ||||
| 181 | + | tb := sess.activeTab() | |||
| 182 | + | var got []byte | |||
| 183 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 184 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 185 | + | require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone") | |||
| 186 | + | } | |||
| 187 | + | ||||
| 113 | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | 188 | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | ||
| 114 | p, release := newBlockingPTY(t) | 189 | p, release := newBlockingPTY(t) | ||
| 115 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | 190 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/theme/theme.go +38 -24
| @@ -88,9 +88,13 @@ type Scanner struct { | |||
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST. | |
| 91 | - | // All non-matching bytes are emitted through onBytes in original order. Partial | |
| 92 | - | // OSC color responses are buffered across calls, but the buffer is bounded so an | |
| 93 | - | // unterminated OSC cannot block unrelated input forever. | |
| 91 | + | // All non-matching bytes are emitted through onBytes in original order, and a | |
| 92 | + | // contiguous run of ordinary bytes (including any ESC that does not start a | |
| 93 | + | // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse | |
| 94 | + | // reports or arrow keys) is always delivered as a single onBytes call so | |
| 95 | + | // callers never see it split. Partial OSC color responses are buffered across | |
| 96 | + | // calls, but the buffer is bounded so an unterminated OSC cannot block | |
| 97 | + | // unrelated input forever. | |
| 94 | 98 | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | |
| 95 | 99 | if len(s.pending) > 0 { | |
| 96 | 100 | combined := make([]byte, 0, len(s.pending)+len(data)) | |
| @@ -100,44 +104,54 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||
| 100 | 104 | s.pending = nil | |
| 101 | 105 | } | |
| 102 | 106 | ||
| 103 | - | for pos := 0; pos < len(data); { | |
| 104 | - | rel := bytes.IndexByte(data[pos:], '\x1b') | |
| 105 | - | if rel < 0 { | |
| 106 | - | onBytes(data[pos:]) | |
| 107 | - | return | |
| 108 | - | } | |
| 109 | - | start := pos + rel | |
| 110 | - | if pos < start { | |
| 111 | - | onBytes(data[pos:start]) | |
| 107 | + | byteStart := 0 | |
| 108 | + | for i := 0; i < len(data); i++ { | |
| 109 | + | if data[i] != '\x1b' { | |
| 110 | + | continue | |
| 112 | 111 | } | |
| 113 | 112 | ||
| 114 | - | completePrefix, possiblePrefix := colorOSCPrefix(data[start:]) | |
| 113 | + | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |
| 115 | 114 | if !completePrefix { | |
| 116 | 115 | if possiblePrefix { | |
| 117 | - | s.bufferOrFlush(data[start:], onBytes) | |
| 116 | + | if byteStart < i { | |
| 117 | + | onBytes(data[byteStart:i]) | |
| 118 | + | } | |
| 119 | + | s.bufferOrFlush(data[i:], onBytes) | |
| 118 | 120 | return | |
| 119 | 121 | } | |
| 120 | - | onBytes(data[start : start+1]) | |
| 121 | - | pos = start + 1 | |
| 122 | + | // Not the start of a color-OSC sequence: leave this ESC inside | |
| 123 | + | // the current passthrough run instead of splitting it out. It | |
| 124 | + | // may be the introducer of an unrelated escape sequence (SGR | |
| 125 | + | // mouse report, arrow key, etc.) that must reach the reader | |
| 126 | + | // intact. | |
| 122 | 127 | continue | |
| 123 | 128 | } | |
| 124 | 129 | ||
| 125 | - | termStart, termEnd := findTerminator(data[start+5:]) | |
| 130 | + | if byteStart < i { | |
| 131 | + | onBytes(data[byteStart:i]) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | termStart, termEnd := findTerminator(data[i+5:]) | |
| 126 | 135 | if termStart < 0 { | |
| 127 | - | s.bufferOrFlush(data[start:], onBytes) | |
| 136 | + | s.bufferOrFlush(data[i:], onBytes) | |
| 128 | 137 | return | |
| 129 | 138 | } | |
| 130 | - | termStart += start + 5 | |
| 131 | - | termEnd += start + 5 | |
| 132 | - | raw := data[start:termEnd] | |
| 133 | - | kind := int(data[start+3]-'0') + 10 | |
| 134 | - | rgb, ok := ParseXColor(string(data[start+5 : termStart])) | |
| 139 | + | termStart += i + 5 | |
| 140 | + | termEnd += i + 5 | |
| 141 | + | raw := data[i:termEnd] | |
| 142 | + | kind := int(data[i+3]-'0') + 10 | |
| 143 | + | rgb, ok := ParseXColor(string(data[i+5 : termStart])) | |
| 135 | 144 | if ok { | |
| 136 | 145 | onColor(kind, rgb) | |
| 137 | 146 | } else { | |
| 138 | 147 | onBytes(raw) | |
| 139 | 148 | } | |
| 140 | - | pos = termEnd | |
| 149 | + | i = termEnd - 1 | |
| 150 | + | byteStart = termEnd | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | if byteStart < len(data) { | |
| 154 | + | onBytes(data[byteStart:]) | |
| 141 | 155 | } | |
| 142 | 156 | } | |
| 143 | 157 | ||
| @@ -88,9 +88,13 @@ type Scanner struct { | |||||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST. | 90 | // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST. | ||
| 91 | - | // All non-matching bytes are emitted through onBytes in original order. Partial | 91 | + | // All non-matching bytes are emitted through onBytes in original order, and a |
| 92 | - | // OSC color responses are buffered across calls, but the buffer is bounded so an | 92 | + | // contiguous run of ordinary bytes (including any ESC that does not start a |
| 93 | - | // unterminated OSC cannot block unrelated input forever. | 93 | + | // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse |
| 94 | + | // reports or arrow keys) is always delivered as a single onBytes call so | |||
| 95 | + | // callers never see it split. Partial OSC color responses are buffered across | |||
| 96 | + | // calls, but the buffer is bounded so an unterminated OSC cannot block | |||
| 97 | + | // unrelated input forever. | |||
| 94 | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | 98 | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | ||
| 95 | if len(s.pending) > 0 { | 99 | if len(s.pending) > 0 { | ||
| 96 | combined := make([]byte, 0, len(s.pending)+len(data)) | 100 | combined := make([]byte, 0, len(s.pending)+len(data)) | ||
| @@ -100,44 +104,54 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||||
| 100 | s.pending = nil | 104 | s.pending = nil | ||
| 101 | } | 105 | } | ||
| 102 | 106 | ||||
| 103 | - | for pos := 0; pos < len(data); { | 107 | + | byteStart := 0 |
| 104 | - | rel := bytes.IndexByte(data[pos:], '\x1b') | 108 | + | for i := 0; i < len(data); i++ { |
| 105 | - | if rel < 0 { | 109 | + | if data[i] != '\x1b' { |
| 106 | - | onBytes(data[pos:]) | 110 | + | continue |
| 107 | - | return | |||
| 108 | - | } | |||
| 109 | - | start := pos + rel | |||
| 110 | - | if pos < start { | |||
| 111 | - | onBytes(data[pos:start]) | |||
| 112 | } | 111 | } | ||
| 113 | 112 | ||||
| 114 | - | completePrefix, possiblePrefix := colorOSCPrefix(data[start:]) | 113 | + | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) |
| 115 | if !completePrefix { | 114 | if !completePrefix { | ||
| 116 | if possiblePrefix { | 115 | if possiblePrefix { | ||
| 117 | - | s.bufferOrFlush(data[start:], onBytes) | 116 | + | if byteStart < i { |
| 117 | + | onBytes(data[byteStart:i]) | |||
| 118 | + | } | |||
| 119 | + | s.bufferOrFlush(data[i:], onBytes) | |||
| 118 | return | 120 | return | ||
| 119 | } | 121 | } | ||
| 120 | - | onBytes(data[start : start+1]) | 122 | + | // Not the start of a color-OSC sequence: leave this ESC inside |
| 121 | - | pos = start + 1 | 123 | + | // the current passthrough run instead of splitting it out. It |
| 124 | + | // may be the introducer of an unrelated escape sequence (SGR | |||
| 125 | + | // mouse report, arrow key, etc.) that must reach the reader | |||
| 126 | + | // intact. | |||
| 122 | continue | 127 | continue | ||
| 123 | } | 128 | } | ||
| 124 | 129 | ||||
| 125 | - | termStart, termEnd := findTerminator(data[start+5:]) | 130 | + | if byteStart < i { |
| 131 | + | onBytes(data[byteStart:i]) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | termStart, termEnd := findTerminator(data[i+5:]) | |||
| 126 | if termStart < 0 { | 135 | if termStart < 0 { | ||
| 127 | - | s.bufferOrFlush(data[start:], onBytes) | 136 | + | s.bufferOrFlush(data[i:], onBytes) |
| 128 | return | 137 | return | ||
| 129 | } | 138 | } | ||
| 130 | - | termStart += start + 5 | 139 | + | termStart += i + 5 |
| 131 | - | termEnd += start + 5 | 140 | + | termEnd += i + 5 |
| 132 | - | raw := data[start:termEnd] | 141 | + | raw := data[i:termEnd] |
| 133 | - | kind := int(data[start+3]-'0') + 10 | 142 | + | kind := int(data[i+3]-'0') + 10 |
| 134 | - | rgb, ok := ParseXColor(string(data[start+5 : termStart])) | 143 | + | rgb, ok := ParseXColor(string(data[i+5 : termStart])) |
| 135 | if ok { | 144 | if ok { | ||
| 136 | onColor(kind, rgb) | 145 | onColor(kind, rgb) | ||
| 137 | } else { | 146 | } else { | ||
| 138 | onBytes(raw) | 147 | onBytes(raw) | ||
| 139 | } | 148 | } | ||
| 140 | - | pos = termEnd | 149 | + | i = termEnd - 1 |
| 150 | + | byteStart = termEnd | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | if byteStart < len(data) { | |||
| 154 | + | onBytes(data[byteStart:]) | |||
| 141 | } | 155 | } | ||
| 142 | } | 156 | } | ||
| 143 | 157 | ||||
M internal/usecase/theme/theme_test.go +72 -0
| @@ -113,6 +113,78 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||
| 113 | 113 | } | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | + | func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |
| 117 | + | var s Scanner | |
| 118 | + | var chunks [][]byte | |
| 119 | + | colors := 0 | |
| 120 | + | ||
| 121 | + | in := []byte("\x1b[<0;1;1M") | |
| 122 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { | |
| 123 | + | chunks = append(chunks, append([]byte(nil), b...)) | |
| 124 | + | }) | |
| 125 | + | ||
| 126 | + | if colors != 0 { | |
| 127 | + | t.Fatalf("colors=%d want 0", colors) | |
| 128 | + | } | |
| 129 | + | if len(chunks) != 1 { | |
| 130 | + | t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks) | |
| 131 | + | } | |
| 132 | + | if !bytes.Equal(chunks[0], in) { | |
| 133 | + | t.Fatalf("chunk=%q want %q", chunks[0], in) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |
| 138 | + | var s Scanner | |
| 139 | + | var chunks [][]byte | |
| 140 | + | ||
| 141 | + | in := []byte("\x1b[A") | |
| 142 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) { | |
| 143 | + | chunks = append(chunks, append([]byte(nil), b...)) | |
| 144 | + | }) | |
| 145 | + | ||
| 146 | + | if len(chunks) != 1 { | |
| 147 | + | t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks) | |
| 148 | + | } | |
| 149 | + | if !bytes.Equal(chunks[0], in) { | |
| 150 | + | t.Fatalf("chunk=%q want %q", chunks[0], in) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |
| 155 | + | var s Scanner | |
| 156 | + | var chunks [][]byte | |
| 157 | + | var colors []struct { | |
| 158 | + | kind int | |
| 159 | + | rgb renderer.RGB | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) { | |
| 163 | + | colors = append(colors, struct { | |
| 164 | + | kind int | |
| 165 | + | rgb renderer.RGB | |
| 166 | + | }{kind: kind, rgb: rgb}) | |
| 167 | + | }, func(b []byte) { | |
| 168 | + | chunks = append(chunks, append([]byte(nil), b...)) | |
| 169 | + | }) | |
| 170 | + | ||
| 171 | + | if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) { | |
| 172 | + | t.Fatalf("colors=%+v", colors) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | var joined bytes.Buffer | |
| 176 | + | for _, c := range chunks { | |
| 177 | + | joined.Write(c) | |
| 178 | + | } | |
| 179 | + | if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want { | |
| 180 | + | t.Fatalf("joined=%q want %q", joined.String(), want) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" { | |
| 184 | + | t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef") | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 116 | 188 | func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |
| 117 | 189 | var s Scanner | |
| 118 | 190 | var out bytes.Buffer | |
| @@ -113,6 +113,78 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||||
| 113 | } | 113 | } | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | + | func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |||
| 117 | + | var s Scanner | |||
| 118 | + | var chunks [][]byte | |||
| 119 | + | colors := 0 | |||
| 120 | + | ||||
| 121 | + | in := []byte("\x1b[<0;1;1M") | |||
| 122 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { | |||
| 123 | + | chunks = append(chunks, append([]byte(nil), b...)) | |||
| 124 | + | }) | |||
| 125 | + | ||||
| 126 | + | if colors != 0 { | |||
| 127 | + | t.Fatalf("colors=%d want 0", colors) | |||
| 128 | + | } | |||
| 129 | + | if len(chunks) != 1 { | |||
| 130 | + | t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks) | |||
| 131 | + | } | |||
| 132 | + | if !bytes.Equal(chunks[0], in) { | |||
| 133 | + | t.Fatalf("chunk=%q want %q", chunks[0], in) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |||
| 138 | + | var s Scanner | |||
| 139 | + | var chunks [][]byte | |||
| 140 | + | ||||
| 141 | + | in := []byte("\x1b[A") | |||
| 142 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) { | |||
| 143 | + | chunks = append(chunks, append([]byte(nil), b...)) | |||
| 144 | + | }) | |||
| 145 | + | ||||
| 146 | + | if len(chunks) != 1 { | |||
| 147 | + | t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks) | |||
| 148 | + | } | |||
| 149 | + | if !bytes.Equal(chunks[0], in) { | |||
| 150 | + | t.Fatalf("chunk=%q want %q", chunks[0], in) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |||
| 155 | + | var s Scanner | |||
| 156 | + | var chunks [][]byte | |||
| 157 | + | var colors []struct { | |||
| 158 | + | kind int | |||
| 159 | + | rgb renderer.RGB | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) { | |||
| 163 | + | colors = append(colors, struct { | |||
| 164 | + | kind int | |||
| 165 | + | rgb renderer.RGB | |||
| 166 | + | }{kind: kind, rgb: rgb}) | |||
| 167 | + | }, func(b []byte) { | |||
| 168 | + | chunks = append(chunks, append([]byte(nil), b...)) | |||
| 169 | + | }) | |||
| 170 | + | ||||
| 171 | + | if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) { | |||
| 172 | + | t.Fatalf("colors=%+v", colors) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | var joined bytes.Buffer | |||
| 176 | + | for _, c := range chunks { | |||
| 177 | + | joined.Write(c) | |||
| 178 | + | } | |||
| 179 | + | if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want { | |||
| 180 | + | t.Fatalf("joined=%q want %q", joined.String(), want) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" { | |||
| 184 | + | t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef") | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 116 | func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | 188 | func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | ||
| 117 | var s Scanner | 189 | var s Scanner | ||
| 118 | var out bytes.Buffer | 190 | var out bytes.Buffer | ||