merge(feature): terminal theme inheritance
29 files · +1499 -64
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 102 | 102 | ||
| 103 | 103 | select { | |
| 104 | 104 | case got := <-captured: | |
| 105 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 105 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 106 | 106 | if string(got) != want { | |
| 107 | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 108 | 108 | } | |
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 102 | 102 | ||||
| 103 | select { | 103 | select { | ||
| 104 | case got := <-captured: | 104 | case got := <-captured: | ||
| 105 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | 105 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 106 | if string(got) != want { | 106 | if string(got) != want { | ||
| 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 108 | } | 108 | } | ||
M internal/adapters/term/terminal.go +2 -1
| @@ -28,6 +28,7 @@ const ( | |||
| 28 | 28 | cursorStyleDefault = "\x1b[0 q" | |
| 29 | 29 | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 30 | 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 31 | + | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 31 | 32 | ) | |
| 32 | 33 | ||
| 33 | 34 | // bufSize is the batched writer's buffer capacity. | |
| @@ -104,7 +105,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 104 | 105 | } | |
| 105 | 106 | t.orig = old | |
| 106 | 107 | ||
| 107 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil { | |
| 108 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil { | |
| 108 | 109 | _ = t.restoreRawLocked() | |
| 109 | 110 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 110 | 111 | } | |
| @@ -28,6 +28,7 @@ const ( | |||||
| 28 | cursorStyleDefault = "\x1b[0 q" | 28 | cursorStyleDefault = "\x1b[0 q" | ||
| 29 | mouseEnable = "\x1b[?1002h\x1b[?1006h" | 29 | mouseEnable = "\x1b[?1002h\x1b[?1006h" | ||
| 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | 30 | mouseDisable = "\x1b[?1002l\x1b[?1006l" | ||
| 31 | + | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |||
| 31 | ) | 32 | ) | ||
| 32 | 33 | ||||
| 33 | // bufSize is the batched writer's buffer capacity. | 34 | // bufSize is the batched writer's buffer capacity. | ||
| @@ -104,7 +105,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 104 | } | 105 | } | ||
| 105 | t.orig = old | 106 | t.orig = old | ||
| 106 | 107 | ||||
| 107 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil { | 108 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil { |
| 108 | _ = t.restoreRawLocked() | 109 | _ = t.restoreRawLocked() | ||
| 109 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 110 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 110 | } | 111 | } | ||
M internal/adapters/term/terminal_test.go +5 -5
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 60 | _ = outW.Close() | |
| 61 | 61 | <-done | |
| 62 | 62 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 63 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 64 | 64 | if got := captured.String(); got != want { | |
| 65 | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 66 | } | |
| @@ -98,10 +98,10 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 98 | 98 | _ = outW.Close() | |
| 99 | 99 | <-done | |
| 100 | 100 | ||
| 101 | - | // Alt-screen/cursor escapes must appear exactly once for enter and | |
| 102 | - | // once for exit, regardless of how many times EnterRaw/restore were | |
| 103 | - | // called. | |
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 101 | + | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | |
| 102 | + | // cursor/alt-screen exits exactly once, regardless of how many times | |
| 103 | + | // EnterRaw/restore were called. | |
| 104 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 105 | 105 | if got := captured.String(); got != want { | |
| 106 | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 107 | 107 | } | |
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 60 | _ = outW.Close() | ||
| 61 | <-done | 61 | <-done | ||
| 62 | 62 | ||||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | 63 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 64 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 66 | } | ||
| @@ -98,10 +98,10 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 98 | _ = outW.Close() | 98 | _ = outW.Close() | ||
| 99 | <-done | 99 | <-done | ||
| 100 | 100 | ||||
| 101 | - | // Alt-screen/cursor escapes must appear exactly once for enter and | 101 | + | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and |
| 102 | - | // once for exit, regardless of how many times EnterRaw/restore were | 102 | + | // cursor/alt-screen exits exactly once, regardless of how many times |
| 103 | - | // called. | 103 | + | // EnterRaw/restore were called. |
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | 104 | + | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 105 | if got := captured.String(); got != want { | 105 | if got := captured.String(); got != want { | ||
| 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 107 | } | 107 | } | ||
M internal/ports/frame.go +4 -2
| @@ -6,8 +6,9 @@ const ProtocolVersion uint16 = 3 | |||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| 8 | 8 | ||
| 9 | - | // Frame message types. Client-originated messages are numbered from 1, | |
| 10 | - | // server-originated messages from 16, leaving room for growth in each band. | |
| 9 | + | // Frame message types. Client-originated messages are numbered from 1 | |
| 10 | + | // through 15, server-originated messages from 16, leaving room for growth in | |
| 11 | + | // each band. | |
| 11 | 12 | const ( | |
| 12 | 13 | MsgHello MsgType = 1 | |
| 13 | 14 | MsgInput MsgType = 2 | |
| @@ -16,6 +17,7 @@ const ( | |||
| 16 | 17 | MsgPing MsgType = 5 | |
| 17 | 18 | MsgList MsgType = 6 | |
| 18 | 19 | MsgKill MsgType = 7 | |
| 20 | + | MsgTheme MsgType = 8 | |
| 19 | 21 | ||
| 20 | 22 | MsgWelcome MsgType = 16 | |
| 21 | 23 | MsgError MsgType = 17 | |
| @@ -6,8 +6,9 @@ const ProtocolVersion uint16 = 3 | |||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| 8 | 8 | ||||
| 9 | - | // Frame message types. Client-originated messages are numbered from 1, | 9 | + | // Frame message types. Client-originated messages are numbered from 1 |
| 10 | - | // server-originated messages from 16, leaving room for growth in each band. | 10 | + | // through 15, server-originated messages from 16, leaving room for growth in |
| 11 | + | // each band. | |||
| 11 | const ( | 12 | const ( | ||
| 12 | MsgHello MsgType = 1 | 13 | MsgHello MsgType = 1 | ||
| 13 | MsgInput MsgType = 2 | 14 | MsgInput MsgType = 2 | ||
| @@ -16,6 +17,7 @@ const ( | |||||
| 16 | MsgPing MsgType = 5 | 17 | MsgPing MsgType = 5 | ||
| 17 | MsgList MsgType = 6 | 18 | MsgList MsgType = 6 | ||
| 18 | MsgKill MsgType = 7 | 19 | MsgKill MsgType = 7 | ||
| 20 | + | MsgTheme MsgType = 8 | |||
| 19 | 21 | ||||
| 20 | MsgWelcome MsgType = 16 | 22 | MsgWelcome MsgType = 16 | ||
| 21 | MsgError MsgType = 17 | 23 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +1 -0
| @@ -14,6 +14,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 14 | 14 | {"MsgPing", MsgPing}, | |
| 15 | 15 | {"MsgList", MsgList}, | |
| 16 | 16 | {"MsgKill", MsgKill}, | |
| 17 | + | {"MsgTheme", MsgTheme}, | |
| 17 | 18 | {"MsgWelcome", MsgWelcome}, | |
| 18 | 19 | {"MsgError", MsgError}, | |
| 19 | 20 | {"MsgOutput", MsgOutput}, | |
| @@ -14,6 +14,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 14 | {"MsgPing", MsgPing}, | 14 | {"MsgPing", MsgPing}, | ||
| 15 | {"MsgList", MsgList}, | 15 | {"MsgList", MsgList}, | ||
| 16 | {"MsgKill", MsgKill}, | 16 | {"MsgKill", MsgKill}, | ||
| 17 | + | {"MsgTheme", MsgTheme}, | |||
| 17 | {"MsgWelcome", MsgWelcome}, | 18 | {"MsgWelcome", MsgWelcome}, | ||
| 18 | {"MsgError", MsgError}, | 19 | {"MsgError", MsgError}, | ||
| 19 | {"MsgOutput", MsgOutput}, | 20 | {"MsgOutput", MsgOutput}, | ||
M internal/ports/wire.go +73 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "errors" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // errShortPayload is returned when a payload ends before a required field | |
| @@ -65,6 +66,16 @@ type Resize struct { | |||
| 65 | 66 | Size domain.Size | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 69 | + | // Theme reports the client's terminal foreground/background colors and | |
| 70 | + | // whether the client terminal supports truecolor. | |
| 71 | + | type Theme struct { | |
| 72 | + | HasForeground bool | |
| 73 | + | Foreground renderer.RGB | |
| 74 | + | HasBackground bool | |
| 75 | + | Background renderer.RGB | |
| 76 | + | TrueColor bool | |
| 77 | + | } | |
| 78 | + | ||
| 68 | 79 | // Detach asks the daemon to detach the current client without killing the | |
| 69 | 80 | // session. | |
| 70 | 81 | type Detach struct{} | |
| @@ -272,6 +283,68 @@ func MarshalResize(m Resize) []byte { | |||
| 272 | 283 | return w.b | |
| 273 | 284 | } | |
| 274 | 285 | ||
| 286 | + | // MarshalTheme encodes m into a fixed-width Theme message payload. | |
| 287 | + | func MarshalTheme(m Theme) []byte { | |
| 288 | + | var flags uint8 | |
| 289 | + | if m.HasForeground { | |
| 290 | + | flags |= 0x01 | |
| 291 | + | } | |
| 292 | + | if m.HasBackground { | |
| 293 | + | flags |= 0x02 | |
| 294 | + | } | |
| 295 | + | if m.TrueColor { | |
| 296 | + | flags |= 0x04 | |
| 297 | + | } | |
| 298 | + | return []byte{ | |
| 299 | + | flags, | |
| 300 | + | m.Foreground.R, m.Foreground.G, m.Foreground.B, | |
| 301 | + | m.Background.R, m.Background.G, m.Background.B, | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // UnmarshalTheme decodes a fixed-width Theme message payload. | |
| 306 | + | func UnmarshalTheme(b []byte) (Theme, error) { | |
| 307 | + | r := payloadReader{b: b} | |
| 308 | + | flags, err := r.getUint8() | |
| 309 | + | if err != nil { | |
| 310 | + | return Theme{}, err | |
| 311 | + | } | |
| 312 | + | fgR, err := r.getUint8() | |
| 313 | + | if err != nil { | |
| 314 | + | return Theme{}, err | |
| 315 | + | } | |
| 316 | + | fgG, err := r.getUint8() | |
| 317 | + | if err != nil { | |
| 318 | + | return Theme{}, err | |
| 319 | + | } | |
| 320 | + | fgB, err := r.getUint8() | |
| 321 | + | if err != nil { | |
| 322 | + | return Theme{}, err | |
| 323 | + | } | |
| 324 | + | bgR, err := r.getUint8() | |
| 325 | + | if err != nil { | |
| 326 | + | return Theme{}, err | |
| 327 | + | } | |
| 328 | + | bgG, err := r.getUint8() | |
| 329 | + | if err != nil { | |
| 330 | + | return Theme{}, err | |
| 331 | + | } | |
| 332 | + | bgB, err := r.getUint8() | |
| 333 | + | if err != nil { | |
| 334 | + | return Theme{}, err | |
| 335 | + | } | |
| 336 | + | if err := r.done(); err != nil { | |
| 337 | + | return Theme{}, err | |
| 338 | + | } | |
| 339 | + | return Theme{ | |
| 340 | + | HasForeground: flags&0x01 != 0, | |
| 341 | + | Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB}, | |
| 342 | + | HasBackground: flags&0x02 != 0, | |
| 343 | + | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | |
| 344 | + | TrueColor: flags&0x04 != 0, | |
| 345 | + | }, nil | |
| 346 | + | } | |
| 347 | + | ||
| 275 | 348 | // UnmarshalResize decodes a Resize message payload. | |
| 276 | 349 | func UnmarshalResize(b []byte) (Resize, error) { | |
| 277 | 350 | r := payloadReader{b: b} | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "errors" | 11 | "errors" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // errShortPayload is returned when a payload ends before a required field | 17 | // errShortPayload is returned when a payload ends before a required field | ||
| @@ -65,6 +66,16 @@ type Resize struct { | |||||
| 65 | Size domain.Size | 66 | Size domain.Size | ||
| 66 | } | 67 | } | ||
| 67 | 68 | ||||
| 69 | + | // Theme reports the client's terminal foreground/background colors and | |||
| 70 | + | // whether the client terminal supports truecolor. | |||
| 71 | + | type Theme struct { | |||
| 72 | + | HasForeground bool | |||
| 73 | + | Foreground renderer.RGB | |||
| 74 | + | HasBackground bool | |||
| 75 | + | Background renderer.RGB | |||
| 76 | + | TrueColor bool | |||
| 77 | + | } | |||
| 78 | + | ||||
| 68 | // Detach asks the daemon to detach the current client without killing the | 79 | // Detach asks the daemon to detach the current client without killing the | ||
| 69 | // session. | 80 | // session. | ||
| 70 | type Detach struct{} | 81 | type Detach struct{} | ||
| @@ -272,6 +283,68 @@ func MarshalResize(m Resize) []byte { | |||||
| 272 | return w.b | 283 | return w.b | ||
| 273 | } | 284 | } | ||
| 274 | 285 | ||||
| 286 | + | // MarshalTheme encodes m into a fixed-width Theme message payload. | |||
| 287 | + | func MarshalTheme(m Theme) []byte { | |||
| 288 | + | var flags uint8 | |||
| 289 | + | if m.HasForeground { | |||
| 290 | + | flags |= 0x01 | |||
| 291 | + | } | |||
| 292 | + | if m.HasBackground { | |||
| 293 | + | flags |= 0x02 | |||
| 294 | + | } | |||
| 295 | + | if m.TrueColor { | |||
| 296 | + | flags |= 0x04 | |||
| 297 | + | } | |||
| 298 | + | return []byte{ | |||
| 299 | + | flags, | |||
| 300 | + | m.Foreground.R, m.Foreground.G, m.Foreground.B, | |||
| 301 | + | m.Background.R, m.Background.G, m.Background.B, | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // UnmarshalTheme decodes a fixed-width Theme message payload. | |||
| 306 | + | func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 307 | + | r := payloadReader{b: b} | |||
| 308 | + | flags, err := r.getUint8() | |||
| 309 | + | if err != nil { | |||
| 310 | + | return Theme{}, err | |||
| 311 | + | } | |||
| 312 | + | fgR, err := r.getUint8() | |||
| 313 | + | if err != nil { | |||
| 314 | + | return Theme{}, err | |||
| 315 | + | } | |||
| 316 | + | fgG, err := r.getUint8() | |||
| 317 | + | if err != nil { | |||
| 318 | + | return Theme{}, err | |||
| 319 | + | } | |||
| 320 | + | fgB, err := r.getUint8() | |||
| 321 | + | if err != nil { | |||
| 322 | + | return Theme{}, err | |||
| 323 | + | } | |||
| 324 | + | bgR, err := r.getUint8() | |||
| 325 | + | if err != nil { | |||
| 326 | + | return Theme{}, err | |||
| 327 | + | } | |||
| 328 | + | bgG, err := r.getUint8() | |||
| 329 | + | if err != nil { | |||
| 330 | + | return Theme{}, err | |||
| 331 | + | } | |||
| 332 | + | bgB, err := r.getUint8() | |||
| 333 | + | if err != nil { | |||
| 334 | + | return Theme{}, err | |||
| 335 | + | } | |||
| 336 | + | if err := r.done(); err != nil { | |||
| 337 | + | return Theme{}, err | |||
| 338 | + | } | |||
| 339 | + | return Theme{ | |||
| 340 | + | HasForeground: flags&0x01 != 0, | |||
| 341 | + | Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB}, | |||
| 342 | + | HasBackground: flags&0x02 != 0, | |||
| 343 | + | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | |||
| 344 | + | TrueColor: flags&0x04 != 0, | |||
| 345 | + | }, nil | |||
| 346 | + | } | |||
| 347 | + | ||||
| 275 | // UnmarshalResize decodes a Resize message payload. | 348 | // UnmarshalResize decodes a Resize message payload. | ||
| 276 | func UnmarshalResize(b []byte) (Resize, error) { | 349 | func UnmarshalResize(b []byte) (Resize, error) { | ||
| 277 | r := payloadReader{b: b} | 350 | r := payloadReader{b: b} | ||
M internal/ports/wire_test.go +55 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // mustNotPanic runs f and fails the test (instead of crashing the test | |
| @@ -139,6 +140,60 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 139 | 140 | }) | |
| 140 | 141 | } | |
| 141 | 142 | ||
| 143 | + | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| 144 | + | tests := []struct { | |
| 145 | + | name string | |
| 146 | + | msg Theme | |
| 147 | + | want []byte | |
| 148 | + | }{ | |
| 149 | + | { | |
| 150 | + | name: "foreground background truecolor", | |
| 151 | + | msg: Theme{ | |
| 152 | + | HasForeground: true, | |
| 153 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 154 | + | HasBackground: true, | |
| 155 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 156 | + | TrueColor: true, | |
| 157 | + | }, | |
| 158 | + | want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | |
| 159 | + | }, | |
| 160 | + | { | |
| 161 | + | name: "foreground only no truecolor", | |
| 162 | + | msg: Theme{ | |
| 163 | + | HasForeground: true, | |
| 164 | + | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 165 | + | Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 166 | + | }, | |
| 167 | + | want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, | |
| 168 | + | }, | |
| 169 | + | { | |
| 170 | + | name: "empty", | |
| 171 | + | msg: Theme{}, | |
| 172 | + | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 173 | + | }, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | for _, tt := range tests { | |
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |
| 178 | + | got := MarshalTheme(tt.msg) | |
| 179 | + | if !bytes.Equal(got, tt.want) { | |
| 180 | + | t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want) | |
| 181 | + | } | |
| 182 | + | back, err := UnmarshalTheme(got) | |
| 183 | + | if err != nil { | |
| 184 | + | t.Fatalf("UnmarshalTheme() error = %v", err) | |
| 185 | + | } | |
| 186 | + | if !reflect.DeepEqual(back, tt.msg) { | |
| 187 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | full := MarshalTheme(tests[0].msg) | |
| 193 | + | assertAllPrefixesFail(t, full, UnmarshalTheme) | |
| 194 | + | assertTrailingGarbageFails(t, full, UnmarshalTheme) | |
| 195 | + | } | |
| 196 | + | ||
| 142 | 197 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| 143 | 198 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | |
| 144 | 199 | want := []byte{0x00, 0x64, 0x00, 0x28} | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // mustNotPanic runs f and fails the test (instead of crashing the test | 13 | // mustNotPanic runs f and fails the test (instead of crashing the test | ||
| @@ -139,6 +140,60 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||||
| 139 | }) | 140 | }) | ||
| 140 | } | 141 | } | ||
| 141 | 142 | ||||
| 143 | + | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||
| 144 | + | tests := []struct { | |||
| 145 | + | name string | |||
| 146 | + | msg Theme | |||
| 147 | + | want []byte | |||
| 148 | + | }{ | |||
| 149 | + | { | |||
| 150 | + | name: "foreground background truecolor", | |||
| 151 | + | msg: Theme{ | |||
| 152 | + | HasForeground: true, | |||
| 153 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 154 | + | HasBackground: true, | |||
| 155 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 156 | + | TrueColor: true, | |||
| 157 | + | }, | |||
| 158 | + | want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | |||
| 159 | + | }, | |||
| 160 | + | { | |||
| 161 | + | name: "foreground only no truecolor", | |||
| 162 | + | msg: Theme{ | |||
| 163 | + | HasForeground: true, | |||
| 164 | + | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 165 | + | Background: renderer.RGB{R: 40, G: 50, B: 60}, | |||
| 166 | + | }, | |||
| 167 | + | want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, | |||
| 168 | + | }, | |||
| 169 | + | { | |||
| 170 | + | name: "empty", | |||
| 171 | + | msg: Theme{}, | |||
| 172 | + | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |||
| 173 | + | }, | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | for _, tt := range tests { | |||
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 178 | + | got := MarshalTheme(tt.msg) | |||
| 179 | + | if !bytes.Equal(got, tt.want) { | |||
| 180 | + | t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want) | |||
| 181 | + | } | |||
| 182 | + | back, err := UnmarshalTheme(got) | |||
| 183 | + | if err != nil { | |||
| 184 | + | t.Fatalf("UnmarshalTheme() error = %v", err) | |||
| 185 | + | } | |||
| 186 | + | if !reflect.DeepEqual(back, tt.msg) { | |||
| 187 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |||
| 188 | + | } | |||
| 189 | + | }) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | full := MarshalTheme(tests[0].msg) | |||
| 193 | + | assertAllPrefixesFail(t, full, UnmarshalTheme) | |||
| 194 | + | assertTrailingGarbageFails(t, full, UnmarshalTheme) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 142 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 197 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| 143 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | 198 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | ||
| 144 | want := []byte{0x00, 0x64, 0x00, 0x28} | 199 | want := []byte{0x00, 0x64, 0x00, 0x28} | ||
M internal/usecase/client/client.go +38 -8
| @@ -14,9 +14,12 @@ import ( | |||
| 14 | 14 | "io" | |
| 15 | 15 | "log/slog" | |
| 16 | 16 | "os" | |
| 17 | + | "strings" | |
| 17 | 18 | ||
| 18 | 19 | "github.com/bnema/vev/internal/domain" | |
| 19 | 20 | "github.com/bnema/vev/internal/ports" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/theme" | |
| 22 | + | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 23 | ) | |
| 21 | 24 | ||
| 22 | 25 | // ProtocolError is a session- or protocol-level failure reported by the | |
| @@ -115,6 +118,8 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 115 | 118 | if err != nil { | |
| 116 | 119 | cwd = "" | |
| 117 | 120 | } | |
| 121 | + | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | |
| 122 | + | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | |
| 118 | 123 | hello := ports.Hello{ | |
| 119 | 124 | Version: ports.ProtocolVersion, | |
| 120 | 125 | Intent: intent, | |
| @@ -168,7 +173,7 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 168 | 173 | sendErrCh := make(chan error, 1) | |
| 169 | 174 | ||
| 170 | 175 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |
| 171 | - | go runStdin(loopCtx, cancel, term.In(), sendCh) | |
| 176 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor) | |
| 172 | 177 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |
| 173 | 178 | ||
| 174 | 179 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -282,17 +287,42 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 282 | 287 | // exits. That is harmless here — Attach has already returned and restored | |
| 283 | 288 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 284 | 289 | // closable stdin duplicate could lift it later if ever needed. | |
| 285 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) { | |
| 290 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | |
| 286 | 291 | buf := make([]byte, stdinBufSize) | |
| 292 | + | var scanner theme.Scanner | |
| 293 | + | current := ports.Theme{TrueColor: trueColor} | |
| 294 | + | send := func(frame ports.Frame) bool { | |
| 295 | + | select { | |
| 296 | + | case out <- frame: | |
| 297 | + | return true | |
| 298 | + | case <-ctx.Done(): | |
| 299 | + | return false | |
| 300 | + | } | |
| 301 | + | } | |
| 287 | 302 | for { | |
| 288 | 303 | n, rerr := in.Read(buf) | |
| 289 | 304 | if n > 0 { | |
| 290 | - | data := make([]byte, n) | |
| 291 | - | copy(data, buf[:n]) | |
| 292 | - | frame := ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | |
| 293 | - | select { | |
| 294 | - | case out <- frame: | |
| 295 | - | case <-ctx.Done(): | |
| 305 | + | ok := true | |
| 306 | + | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 307 | + | switch kind { | |
| 308 | + | case 10: | |
| 309 | + | current.HasForeground = true | |
| 310 | + | current.Foreground = rgb | |
| 311 | + | case 11: | |
| 312 | + | current.HasBackground = true | |
| 313 | + | current.Background = rgb | |
| 314 | + | default: | |
| 315 | + | return | |
| 316 | + | } | |
| 317 | + | ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 318 | + | }, func(data []byte) { | |
| 319 | + | if len(data) == 0 || !ok { | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | copyData := append([]byte(nil), data...) | |
| 323 | + | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})}) | |
| 324 | + | }) | |
| 325 | + | if !ok { | |
| 296 | 326 | return | |
| 297 | 327 | } | |
| 298 | 328 | } | |
| @@ -14,9 +14,12 @@ import ( | |||||
| 14 | "io" | 14 | "io" | ||
| 15 | "log/slog" | 15 | "log/slog" | ||
| 16 | "os" | 16 | "os" | ||
| 17 | + | "strings" | |||
| 17 | 18 | ||||
| 18 | "github.com/bnema/vev/internal/domain" | 19 | "github.com/bnema/vev/internal/domain" | ||
| 19 | "github.com/bnema/vev/internal/ports" | 20 | "github.com/bnema/vev/internal/ports" | ||
| 21 | + | "github.com/bnema/vev/internal/usecase/theme" | |||
| 22 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 20 | ) | 23 | ) | ||
| 21 | 24 | ||||
| 22 | // ProtocolError is a session- or protocol-level failure reported by the | 25 | // ProtocolError is a session- or protocol-level failure reported by the | ||
| @@ -115,6 +118,8 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||||
| 115 | if err != nil { | 118 | if err != nil { | ||
| 116 | cwd = "" | 119 | cwd = "" | ||
| 117 | } | 120 | } | ||
| 121 | + | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | |||
| 122 | + | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | |||
| 118 | hello := ports.Hello{ | 123 | hello := ports.Hello{ | ||
| 119 | Version: ports.ProtocolVersion, | 124 | Version: ports.ProtocolVersion, | ||
| 120 | Intent: intent, | 125 | Intent: intent, | ||
| @@ -168,7 +173,7 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||||
| 168 | sendErrCh := make(chan error, 1) | 173 | sendErrCh := make(chan error, 1) | ||
| 169 | 174 | ||||
| 170 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | 175 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | ||
| 171 | - | go runStdin(loopCtx, cancel, term.In(), sendCh) | 176 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor) |
| 172 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | 177 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | ||
| 173 | 178 | ||||
| 174 | // 5. Output/main loop: the only goroutine that touches the terminal. | 179 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -282,17 +287,42 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 282 | // exits. That is harmless here — Attach has already returned and restored | 287 | // exits. That is harmless here — Attach has already returned and restored | ||
| 283 | // the terminal — and matches the standard pattern for stdin pumps; a | 288 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 284 | // closable stdin duplicate could lift it later if ever needed. | 289 | // closable stdin duplicate could lift it later if ever needed. | ||
| 285 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) { | 290 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { |
| 286 | buf := make([]byte, stdinBufSize) | 291 | buf := make([]byte, stdinBufSize) | ||
| 292 | + | var scanner theme.Scanner | |||
| 293 | + | current := ports.Theme{TrueColor: trueColor} | |||
| 294 | + | send := func(frame ports.Frame) bool { | |||
| 295 | + | select { | |||
| 296 | + | case out <- frame: | |||
| 297 | + | return true | |||
| 298 | + | case <-ctx.Done(): | |||
| 299 | + | return false | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 287 | for { | 302 | for { | ||
| 288 | n, rerr := in.Read(buf) | 303 | n, rerr := in.Read(buf) | ||
| 289 | if n > 0 { | 304 | if n > 0 { | ||
| 290 | - | data := make([]byte, n) | 305 | + | ok := true |
| 291 | - | copy(data, buf[:n]) | 306 | + | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { |
| 292 | - | frame := ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | 307 | + | switch kind { |
| 293 | - | select { | 308 | + | case 10: |
| 294 | - | case out <- frame: | 309 | + | current.HasForeground = true |
| 295 | - | case <-ctx.Done(): | 310 | + | current.Foreground = rgb |
| 311 | + | case 11: | |||
| 312 | + | current.HasBackground = true | |||
| 313 | + | current.Background = rgb | |||
| 314 | + | default: | |||
| 315 | + | return | |||
| 316 | + | } | |||
| 317 | + | ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |||
| 318 | + | }, func(data []byte) { | |||
| 319 | + | if len(data) == 0 || !ok { | |||
| 320 | + | return | |||
| 321 | + | } | |||
| 322 | + | copyData := append([]byte(nil), data...) | |||
| 323 | + | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})}) | |||
| 324 | + | }) | |||
| 325 | + | if !ok { | |||
| 296 | return | 326 | return | ||
| 297 | } | 327 | } | ||
| 298 | } | 328 | } | ||
M internal/usecase/client/client_test.go +299 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "sync" | |
| 9 | 10 | "sync/atomic" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| @@ -66,6 +67,31 @@ func (b *blockingReader) Read(p []byte) (int, error) { | |||
| 66 | 67 | } | |
| 67 | 68 | func (b *blockingReader) unblock() { close(b.ch) } | |
| 68 | 69 | ||
| 70 | + | type oneShotBlockingReader struct { | |
| 71 | + | data []byte | |
| 72 | + | done chan struct{} | |
| 73 | + | once sync.Once | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func newOneShotBlockingReader(data []byte) *oneShotBlockingReader { | |
| 77 | + | return &oneShotBlockingReader{data: data, done: make(chan struct{})} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (r *oneShotBlockingReader) Read(p []byte) (int, error) { | |
| 81 | + | read := false | |
| 82 | + | r.once.Do(func() { | |
| 83 | + | copy(p, r.data) | |
| 84 | + | read = true | |
| 85 | + | }) | |
| 86 | + | if read { | |
| 87 | + | return len(r.data), nil | |
| 88 | + | } | |
| 89 | + | <-r.done | |
| 90 | + | return 0, io.EOF | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (r *oneShotBlockingReader) unblock() { close(r.done) } | |
| 94 | + | ||
| 69 | 95 | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | |
| 70 | 96 | tm := portsmocks.NewMockTerminal(t) | |
| 71 | 97 | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| @@ -172,6 +198,279 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 172 | 198 | require.Equal(t, int32(1), restoreCount.Load()) | |
| 173 | 199 | } | |
| 174 | 200 | ||
| 201 | + | func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |
| 202 | + | t.Setenv("COLORTERM", "truecolor") | |
| 203 | + | ||
| 204 | + | var out bytes.Buffer | |
| 205 | + | var restoreCount atomic.Int32 | |
| 206 | + | resizeCh := make(chan domain.Size) | |
| 207 | + | input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b")) | |
| 208 | + | defer input.unblock() | |
| 209 | + | ||
| 210 | + | tm := portsmocks.NewMockTerminal(t) | |
| 211 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 212 | + | tm.EXPECT().EnterRaw().Return(func() error { | |
| 213 | + | restoreCount.Add(1) | |
| 214 | + | return nil | |
| 215 | + | }, nil).Once() | |
| 216 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 217 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 218 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 219 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 220 | + | ||
| 221 | + | gotTheme := make(chan ports.Theme, 1) | |
| 222 | + | gotInput := make(chan []byte, 2) | |
| 223 | + | allowDetach := make(chan struct{}) | |
| 224 | + | ||
| 225 | + | tr := portsmocks.NewMockTransport(t) | |
| 226 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 227 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 228 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | require.NotContains(t, string(in.Data), "\x1b]11;") | |
| 231 | + | gotInput <- append([]byte(nil), in.Data...) | |
| 232 | + | if bytes.Contains(in.Data, []byte("b")) { | |
| 233 | + | close(allowDetach) | |
| 234 | + | } | |
| 235 | + | return nil | |
| 236 | + | }).Maybe() | |
| 237 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 238 | + | th, err := ports.UnmarshalTheme(f.Payload) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | gotTheme <- th | |
| 241 | + | return nil | |
| 242 | + | }).Once() | |
| 243 | + | ||
| 244 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 245 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 246 | + | recvCh := make(chan recvItem, 1) | |
| 247 | + | recvCh <- recvItem{f: welcome} | |
| 248 | + | closed := make(chan struct{}) | |
| 249 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 250 | + | select { | |
| 251 | + | case it := <-recvCh: | |
| 252 | + | return it.f, it.err | |
| 253 | + | case <-allowDetach: | |
| 254 | + | select { | |
| 255 | + | case <-closed: | |
| 256 | + | return ports.Frame{}, io.EOF | |
| 257 | + | default: | |
| 258 | + | close(closed) | |
| 259 | + | return detached, nil | |
| 260 | + | } | |
| 261 | + | case <-closed: | |
| 262 | + | return ports.Frame{}, io.EOF | |
| 263 | + | } | |
| 264 | + | }).Maybe() | |
| 265 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 266 | + | ||
| 267 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | require.Equal(t, int32(1), restoreCount.Load()) | |
| 270 | + | ||
| 271 | + | select { | |
| 272 | + | case th := <-gotTheme: | |
| 273 | + | require.True(t, th.HasBackground) | |
| 274 | + | require.Equal(t, uint8(1), th.Background.R) | |
| 275 | + | require.Equal(t, uint8(2), th.Background.G) | |
| 276 | + | require.Equal(t, uint8(3), th.Background.B) | |
| 277 | + | require.True(t, th.TrueColor) | |
| 278 | + | case <-time.After(2 * time.Second): | |
| 279 | + | t.Fatal("theme frame was not sent") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | var inputBytes []byte | |
| 283 | + | for { | |
| 284 | + | select { | |
| 285 | + | case b := <-gotInput: | |
| 286 | + | inputBytes = append(inputBytes, b...) | |
| 287 | + | default: | |
| 288 | + | require.Equal(t, []byte("ab"), inputBytes) | |
| 289 | + | return | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |
| 295 | + | t.Setenv("COLORTERM", "") | |
| 296 | + | ||
| 297 | + | var out bytes.Buffer | |
| 298 | + | var restoreCount atomic.Int32 | |
| 299 | + | resizeCh := make(chan domain.Size) | |
| 300 | + | input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07")) | |
| 301 | + | defer input.unblock() | |
| 302 | + | ||
| 303 | + | tm := portsmocks.NewMockTerminal(t) | |
| 304 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 305 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 306 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 307 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 308 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 309 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 310 | + | ||
| 311 | + | gotTheme := make(chan ports.Theme, 1) | |
| 312 | + | allowDetach := make(chan struct{}) | |
| 313 | + | tr := portsmocks.NewMockTransport(t) | |
| 314 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 315 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 316 | + | th, err := ports.UnmarshalTheme(f.Payload) | |
| 317 | + | require.NoError(t, err) | |
| 318 | + | gotTheme <- th | |
| 319 | + | close(allowDetach) | |
| 320 | + | return nil | |
| 321 | + | }).Once() | |
| 322 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 323 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 324 | + | recvCh := make(chan recvItem, 1) | |
| 325 | + | recvCh <- recvItem{f: welcome} | |
| 326 | + | closed := make(chan struct{}) | |
| 327 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 328 | + | select { | |
| 329 | + | case it := <-recvCh: | |
| 330 | + | return it.f, it.err | |
| 331 | + | case <-allowDetach: | |
| 332 | + | select { | |
| 333 | + | case <-closed: | |
| 334 | + | return ports.Frame{}, io.EOF | |
| 335 | + | default: | |
| 336 | + | close(closed) | |
| 337 | + | return detached, nil | |
| 338 | + | } | |
| 339 | + | case <-closed: | |
| 340 | + | return ports.Frame{}, io.EOF | |
| 341 | + | } | |
| 342 | + | }).Maybe() | |
| 343 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 344 | + | ||
| 345 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 346 | + | require.NoError(t, err) | |
| 347 | + | select { | |
| 348 | + | case th := <-gotTheme: | |
| 349 | + | require.True(t, th.HasForeground) | |
| 350 | + | require.False(t, th.TrueColor) | |
| 351 | + | case <-time.After(2 * time.Second): | |
| 352 | + | t.Fatal("theme frame was not sent") | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |
| 357 | + | var out bytes.Buffer | |
| 358 | + | var restoreCount atomic.Int32 | |
| 359 | + | resizeCh := make(chan domain.Size) | |
| 360 | + | input := newOneShotBlockingReader([]byte("\x1b")) | |
| 361 | + | defer input.unblock() | |
| 362 | + | ||
| 363 | + | tm := portsmocks.NewMockTerminal(t) | |
| 364 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 365 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 366 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 367 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 368 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 369 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 370 | + | ||
| 371 | + | gotInput := make(chan []byte, 1) | |
| 372 | + | allowDetach := make(chan struct{}) | |
| 373 | + | tr := portsmocks.NewMockTransport(t) | |
| 374 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 375 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 376 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 377 | + | require.NoError(t, err) | |
| 378 | + | gotInput <- in.Data | |
| 379 | + | close(allowDetach) | |
| 380 | + | return nil | |
| 381 | + | }).Once() | |
| 382 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 383 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 384 | + | recvCh := make(chan recvItem, 1) | |
| 385 | + | recvCh <- recvItem{f: welcome} | |
| 386 | + | closed := make(chan struct{}) | |
| 387 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 388 | + | select { | |
| 389 | + | case it := <-recvCh: | |
| 390 | + | return it.f, it.err | |
| 391 | + | case <-allowDetach: | |
| 392 | + | select { | |
| 393 | + | case <-closed: | |
| 394 | + | return ports.Frame{}, io.EOF | |
| 395 | + | default: | |
| 396 | + | close(closed) | |
| 397 | + | return detached, nil | |
| 398 | + | } | |
| 399 | + | case <-closed: | |
| 400 | + | return ports.Frame{}, io.EOF | |
| 401 | + | } | |
| 402 | + | }).Maybe() | |
| 403 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 404 | + | ||
| 405 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 406 | + | require.NoError(t, err) | |
| 407 | + | select { | |
| 408 | + | case got := <-gotInput: | |
| 409 | + | require.Equal(t, []byte("\x1b"), got) | |
| 410 | + | case <-time.After(2 * time.Second): | |
| 411 | + | t.Fatal("standalone escape input was not sent") | |
| 412 | + | } | |
| 413 | + | } | |
| 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 | + | ||
| 175 | 474 | func TestAttachForwardsResize(t *testing.T) { | |
| 176 | 475 | var out bytes.Buffer | |
| 177 | 476 | var restoreCount atomic.Int32 | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | + | "sync" | |||
| 9 | "sync/atomic" | 10 | "sync/atomic" | ||
| 10 | "testing" | 11 | "testing" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -66,6 +67,31 @@ func (b *blockingReader) Read(p []byte) (int, error) { | |||||
| 66 | } | 67 | } | ||
| 67 | func (b *blockingReader) unblock() { close(b.ch) } | 68 | func (b *blockingReader) unblock() { close(b.ch) } | ||
| 68 | 69 | ||||
| 70 | + | type oneShotBlockingReader struct { | |||
| 71 | + | data []byte | |||
| 72 | + | done chan struct{} | |||
| 73 | + | once sync.Once | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func newOneShotBlockingReader(data []byte) *oneShotBlockingReader { | |||
| 77 | + | return &oneShotBlockingReader{data: data, done: make(chan struct{})} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (r *oneShotBlockingReader) Read(p []byte) (int, error) { | |||
| 81 | + | read := false | |||
| 82 | + | r.once.Do(func() { | |||
| 83 | + | copy(p, r.data) | |||
| 84 | + | read = true | |||
| 85 | + | }) | |||
| 86 | + | if read { | |||
| 87 | + | return len(r.data), nil | |||
| 88 | + | } | |||
| 89 | + | <-r.done | |||
| 90 | + | return 0, io.EOF | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (r *oneShotBlockingReader) unblock() { close(r.done) } | |||
| 94 | + | ||||
| 69 | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | 95 | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | ||
| 70 | tm := portsmocks.NewMockTerminal(t) | 96 | tm := portsmocks.NewMockTerminal(t) | ||
| 71 | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | 97 | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | ||
| @@ -172,6 +198,279 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||||
| 172 | require.Equal(t, int32(1), restoreCount.Load()) | 198 | require.Equal(t, int32(1), restoreCount.Load()) | ||
| 173 | } | 199 | } | ||
| 174 | 200 | ||||
| 201 | + | func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 202 | + | t.Setenv("COLORTERM", "truecolor") | |||
| 203 | + | ||||
| 204 | + | var out bytes.Buffer | |||
| 205 | + | var restoreCount atomic.Int32 | |||
| 206 | + | resizeCh := make(chan domain.Size) | |||
| 207 | + | input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b")) | |||
| 208 | + | defer input.unblock() | |||
| 209 | + | ||||
| 210 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 211 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 212 | + | tm.EXPECT().EnterRaw().Return(func() error { | |||
| 213 | + | restoreCount.Add(1) | |||
| 214 | + | return nil | |||
| 215 | + | }, nil).Once() | |||
| 216 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 217 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 218 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 219 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 220 | + | ||||
| 221 | + | gotTheme := make(chan ports.Theme, 1) | |||
| 222 | + | gotInput := make(chan []byte, 2) | |||
| 223 | + | allowDetach := make(chan struct{}) | |||
| 224 | + | ||||
| 225 | + | tr := portsmocks.NewMockTransport(t) | |||
| 226 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 227 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 228 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | require.NotContains(t, string(in.Data), "\x1b]11;") | |||
| 231 | + | gotInput <- append([]byte(nil), in.Data...) | |||
| 232 | + | if bytes.Contains(in.Data, []byte("b")) { | |||
| 233 | + | close(allowDetach) | |||
| 234 | + | } | |||
| 235 | + | return nil | |||
| 236 | + | }).Maybe() | |||
| 237 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |||
| 238 | + | th, err := ports.UnmarshalTheme(f.Payload) | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | gotTheme <- th | |||
| 241 | + | return nil | |||
| 242 | + | }).Once() | |||
| 243 | + | ||||
| 244 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 245 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 246 | + | recvCh := make(chan recvItem, 1) | |||
| 247 | + | recvCh <- recvItem{f: welcome} | |||
| 248 | + | closed := make(chan struct{}) | |||
| 249 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 250 | + | select { | |||
| 251 | + | case it := <-recvCh: | |||
| 252 | + | return it.f, it.err | |||
| 253 | + | case <-allowDetach: | |||
| 254 | + | select { | |||
| 255 | + | case <-closed: | |||
| 256 | + | return ports.Frame{}, io.EOF | |||
| 257 | + | default: | |||
| 258 | + | close(closed) | |||
| 259 | + | return detached, nil | |||
| 260 | + | } | |||
| 261 | + | case <-closed: | |||
| 262 | + | return ports.Frame{}, io.EOF | |||
| 263 | + | } | |||
| 264 | + | }).Maybe() | |||
| 265 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 266 | + | ||||
| 267 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | require.Equal(t, int32(1), restoreCount.Load()) | |||
| 270 | + | ||||
| 271 | + | select { | |||
| 272 | + | case th := <-gotTheme: | |||
| 273 | + | require.True(t, th.HasBackground) | |||
| 274 | + | require.Equal(t, uint8(1), th.Background.R) | |||
| 275 | + | require.Equal(t, uint8(2), th.Background.G) | |||
| 276 | + | require.Equal(t, uint8(3), th.Background.B) | |||
| 277 | + | require.True(t, th.TrueColor) | |||
| 278 | + | case <-time.After(2 * time.Second): | |||
| 279 | + | t.Fatal("theme frame was not sent") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | var inputBytes []byte | |||
| 283 | + | for { | |||
| 284 | + | select { | |||
| 285 | + | case b := <-gotInput: | |||
| 286 | + | inputBytes = append(inputBytes, b...) | |||
| 287 | + | default: | |||
| 288 | + | require.Equal(t, []byte("ab"), inputBytes) | |||
| 289 | + | return | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |||
| 295 | + | t.Setenv("COLORTERM", "") | |||
| 296 | + | ||||
| 297 | + | var out bytes.Buffer | |||
| 298 | + | var restoreCount atomic.Int32 | |||
| 299 | + | resizeCh := make(chan domain.Size) | |||
| 300 | + | input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07")) | |||
| 301 | + | defer input.unblock() | |||
| 302 | + | ||||
| 303 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 304 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 305 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 306 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 307 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 308 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 309 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 310 | + | ||||
| 311 | + | gotTheme := make(chan ports.Theme, 1) | |||
| 312 | + | allowDetach := make(chan struct{}) | |||
| 313 | + | tr := portsmocks.NewMockTransport(t) | |||
| 314 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 315 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |||
| 316 | + | th, err := ports.UnmarshalTheme(f.Payload) | |||
| 317 | + | require.NoError(t, err) | |||
| 318 | + | gotTheme <- th | |||
| 319 | + | close(allowDetach) | |||
| 320 | + | return nil | |||
| 321 | + | }).Once() | |||
| 322 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 323 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 324 | + | recvCh := make(chan recvItem, 1) | |||
| 325 | + | recvCh <- recvItem{f: welcome} | |||
| 326 | + | closed := make(chan struct{}) | |||
| 327 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 328 | + | select { | |||
| 329 | + | case it := <-recvCh: | |||
| 330 | + | return it.f, it.err | |||
| 331 | + | case <-allowDetach: | |||
| 332 | + | select { | |||
| 333 | + | case <-closed: | |||
| 334 | + | return ports.Frame{}, io.EOF | |||
| 335 | + | default: | |||
| 336 | + | close(closed) | |||
| 337 | + | return detached, nil | |||
| 338 | + | } | |||
| 339 | + | case <-closed: | |||
| 340 | + | return ports.Frame{}, io.EOF | |||
| 341 | + | } | |||
| 342 | + | }).Maybe() | |||
| 343 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 344 | + | ||||
| 345 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |||
| 346 | + | require.NoError(t, err) | |||
| 347 | + | select { | |||
| 348 | + | case th := <-gotTheme: | |||
| 349 | + | require.True(t, th.HasForeground) | |||
| 350 | + | require.False(t, th.TrueColor) | |||
| 351 | + | case <-time.After(2 * time.Second): | |||
| 352 | + | t.Fatal("theme frame was not sent") | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||
| 357 | + | var out bytes.Buffer | |||
| 358 | + | var restoreCount atomic.Int32 | |||
| 359 | + | resizeCh := make(chan domain.Size) | |||
| 360 | + | input := newOneShotBlockingReader([]byte("\x1b")) | |||
| 361 | + | defer input.unblock() | |||
| 362 | + | ||||
| 363 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 364 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 365 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 366 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 367 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 368 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 369 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 370 | + | ||||
| 371 | + | gotInput := make(chan []byte, 1) | |||
| 372 | + | allowDetach := make(chan struct{}) | |||
| 373 | + | tr := portsmocks.NewMockTransport(t) | |||
| 374 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 375 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 376 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | gotInput <- in.Data | |||
| 379 | + | close(allowDetach) | |||
| 380 | + | return nil | |||
| 381 | + | }).Once() | |||
| 382 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 383 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 384 | + | recvCh := make(chan recvItem, 1) | |||
| 385 | + | recvCh <- recvItem{f: welcome} | |||
| 386 | + | closed := make(chan struct{}) | |||
| 387 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 388 | + | select { | |||
| 389 | + | case it := <-recvCh: | |||
| 390 | + | return it.f, it.err | |||
| 391 | + | case <-allowDetach: | |||
| 392 | + | select { | |||
| 393 | + | case <-closed: | |||
| 394 | + | return ports.Frame{}, io.EOF | |||
| 395 | + | default: | |||
| 396 | + | close(closed) | |||
| 397 | + | return detached, nil | |||
| 398 | + | } | |||
| 399 | + | case <-closed: | |||
| 400 | + | return ports.Frame{}, io.EOF | |||
| 401 | + | } | |||
| 402 | + | }).Maybe() | |||
| 403 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 404 | + | ||||
| 405 | + | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |||
| 406 | + | require.NoError(t, err) | |||
| 407 | + | select { | |||
| 408 | + | case got := <-gotInput: | |||
| 409 | + | require.Equal(t, []byte("\x1b"), got) | |||
| 410 | + | case <-time.After(2 * time.Second): | |||
| 411 | + | t.Fatal("standalone escape input was not sent") | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 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 | + | ||||
| 175 | func TestAttachForwardsResize(t *testing.T) { | 474 | func TestAttachForwardsResize(t *testing.T) { | ||
| 176 | var out bytes.Buffer | 475 | var out bytes.Buffer | ||
| 177 | var restoreCount atomic.Int32 | 476 | var restoreCount atomic.Int32 | ||
M internal/usecase/copy/mode.go +31 -7
| @@ -141,10 +141,11 @@ func (m *Mode) SelectedText(s Snapshot) string { | |||
| 141 | 141 | return strings.Join(lines, "\n") | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | - | func (m *Mode) Render(s Snapshot) renderer.Frame { | |
| 144 | + | func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |
| 145 | 145 | m.clamp(s) | |
| 146 | 146 | frame := renderer.NewFrame(s.Width, s.Height+1) | |
| 147 | 147 | lo, hi, selected := m.SelectedBounds() | |
| 148 | + | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | |
| 148 | 149 | for y := range s.Height { | |
| 149 | 150 | src := m.ViewportTop + y | |
| 150 | 151 | if src >= len(s.Rows) { | |
| @@ -155,17 +156,42 @@ func (m *Mode) Render(s Snapshot) renderer.Frame { | |||
| 155 | 156 | lineSelected := selected && src >= lo && src <= hi | |
| 156 | 157 | if lineSelected { | |
| 157 | 158 | for x := range row { | |
| 158 | - | row[x].Style.Inverse = true | |
| 159 | + | applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle) | |
| 159 | 160 | } | |
| 160 | 161 | } | |
| 161 | 162 | if src == m.Cursor && !lineSelected && len(row) > 0 { | |
| 162 | - | row[0].Style.Inverse = true | |
| 163 | + | applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle) | |
| 163 | 164 | } | |
| 164 | 165 | } | |
| 165 | - | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | |
| 166 | + | style := inverseStyle() | |
| 167 | + | if len(styles) > 0 { | |
| 168 | + | style = styles[0] | |
| 169 | + | } | |
| 170 | + | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style) | |
| 166 | 171 | return frame | |
| 167 | 172 | } | |
| 168 | 173 | ||
| 174 | + | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |
| 175 | + | if idx >= len(styles) { | |
| 176 | + | return renderer.Style{}, false | |
| 177 | + | } | |
| 178 | + | return styles[idx], true | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | |
| 182 | + | if !ok || selection.Equal(inverseStyle()) { | |
| 183 | + | dst.Inverse = true | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | *dst = selection | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func inverseStyle() renderer.Style { | |
| 190 | + | style := renderer.DefaultStyle() | |
| 191 | + | style.Inverse = true | |
| 192 | + | return style | |
| 193 | + | } | |
| 194 | + | ||
| 169 | 195 | func rowString(row []renderer.Cell) string { | |
| 170 | 196 | runes := make([]rune, 0, len(row)) | |
| 171 | 197 | for _, c := range row { | |
| @@ -181,7 +207,7 @@ func rowString(row []renderer.Cell) string { | |||
| 181 | 207 | return strings.TrimRight(string(runes), " ") | |
| 182 | 208 | } | |
| 183 | 209 | ||
| 184 | - | func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |
| 210 | + | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { | |
| 185 | 211 | for i := range row { | |
| 186 | 212 | row[i] = renderer.BlankCell() | |
| 187 | 213 | } | |
| @@ -194,8 +220,6 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||
| 194 | 220 | } else { | |
| 195 | 221 | text += "0/0 " | |
| 196 | 222 | } | |
| 197 | - | style := renderer.DefaultStyle() | |
| 198 | - | style.Inverse = true | |
| 199 | 223 | for i, r := range text { | |
| 200 | 224 | if i >= len(row) { | |
| 201 | 225 | break | |
| @@ -141,10 +141,11 @@ func (m *Mode) SelectedText(s Snapshot) string { | |||||
| 141 | return strings.Join(lines, "\n") | 141 | return strings.Join(lines, "\n") | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | - | func (m *Mode) Render(s Snapshot) renderer.Frame { | 144 | + | func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { |
| 145 | m.clamp(s) | 145 | m.clamp(s) | ||
| 146 | frame := renderer.NewFrame(s.Width, s.Height+1) | 146 | frame := renderer.NewFrame(s.Width, s.Height+1) | ||
| 147 | lo, hi, selected := m.SelectedBounds() | 147 | lo, hi, selected := m.SelectedBounds() | ||
| 148 | + | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | |||
| 148 | for y := range s.Height { | 149 | for y := range s.Height { | ||
| 149 | src := m.ViewportTop + y | 150 | src := m.ViewportTop + y | ||
| 150 | if src >= len(s.Rows) { | 151 | if src >= len(s.Rows) { | ||
| @@ -155,17 +156,42 @@ func (m *Mode) Render(s Snapshot) renderer.Frame { | |||||
| 155 | lineSelected := selected && src >= lo && src <= hi | 156 | lineSelected := selected && src >= lo && src <= hi | ||
| 156 | if lineSelected { | 157 | if lineSelected { | ||
| 157 | for x := range row { | 158 | for x := range row { | ||
| 158 | - | row[x].Style.Inverse = true | 159 | + | applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle) |
| 159 | } | 160 | } | ||
| 160 | } | 161 | } | ||
| 161 | if src == m.Cursor && !lineSelected && len(row) > 0 { | 162 | if src == m.Cursor && !lineSelected && len(row) > 0 { | ||
| 162 | - | row[0].Style.Inverse = true | 163 | + | applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle) |
| 163 | } | 164 | } | ||
| 164 | } | 165 | } | ||
| 165 | - | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | 166 | + | style := inverseStyle() |
| 167 | + | if len(styles) > 0 { | |||
| 168 | + | style = styles[0] | |||
| 169 | + | } | |||
| 170 | + | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style) | |||
| 166 | return frame | 171 | return frame | ||
| 167 | } | 172 | } | ||
| 168 | 173 | ||||
| 174 | + | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |||
| 175 | + | if idx >= len(styles) { | |||
| 176 | + | return renderer.Style{}, false | |||
| 177 | + | } | |||
| 178 | + | return styles[idx], true | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | |||
| 182 | + | if !ok || selection.Equal(inverseStyle()) { | |||
| 183 | + | dst.Inverse = true | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | *dst = selection | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func inverseStyle() renderer.Style { | |||
| 190 | + | style := renderer.DefaultStyle() | |||
| 191 | + | style.Inverse = true | |||
| 192 | + | return style | |||
| 193 | + | } | |||
| 194 | + | ||||
| 169 | func rowString(row []renderer.Cell) string { | 195 | func rowString(row []renderer.Cell) string { | ||
| 170 | runes := make([]rune, 0, len(row)) | 196 | runes := make([]rune, 0, len(row)) | ||
| 171 | for _, c := range row { | 197 | for _, c := range row { | ||
| @@ -181,7 +207,7 @@ func rowString(row []renderer.Cell) string { | |||||
| 181 | return strings.TrimRight(string(runes), " ") | 207 | return strings.TrimRight(string(runes), " ") | ||
| 182 | } | 208 | } | ||
| 183 | 209 | ||||
| 184 | - | func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | 210 | + | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { |
| 185 | for i := range row { | 211 | for i := range row { | ||
| 186 | row[i] = renderer.BlankCell() | 212 | row[i] = renderer.BlankCell() | ||
| 187 | } | 213 | } | ||
| @@ -194,8 +220,6 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||||
| 194 | } else { | 220 | } else { | ||
| 195 | text += "0/0 " | 221 | text += "0/0 " | ||
| 196 | } | 222 | } | ||
| 197 | - | style := renderer.DefaultStyle() | |||
| 198 | - | style.Inverse = true | |||
| 199 | for i, r := range text { | 223 | for i, r := range text { | ||
| 200 | if i >= len(row) { | 224 | if i >= len(row) { | ||
| 201 | break | 225 | break | ||
M internal/usecase/copy/mode_test.go +32 -0
| @@ -265,6 +265,38 @@ 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 | + | ||
| 285 | + | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | |
| 286 | + | s := snapshot([]string{"alpha ", "beta "}, 2) | |
| 287 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 288 | + | selection := renderer.DefaultStyle() | |
| 289 | + | selection.HasBackgroundRGB = true | |
| 290 | + | selection.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 291 | + | ||
| 292 | + | frame := m.Render(s, renderer.DefaultStyle(), selection) | |
| 293 | + | ||
| 294 | + | got := frame.At(0, 0).Style | |
| 295 | + | if got.Inverse || !got.HasBackgroundRGB || got.BackgroundRGB != selection.BackgroundRGB { | |
| 296 | + | t.Fatalf("selected cell style = %+v, want themed selection %+v", got, selection) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 268 | 300 | func frameText(row []renderer.Cell) string { | |
| 269 | 301 | var b strings.Builder | |
| 270 | 302 | for _, c := range row { | |
| @@ -265,6 +265,38 @@ 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 | + | ||||
| 285 | + | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | |||
| 286 | + | s := snapshot([]string{"alpha ", "beta "}, 2) | |||
| 287 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |||
| 288 | + | selection := renderer.DefaultStyle() | |||
| 289 | + | selection.HasBackgroundRGB = true | |||
| 290 | + | selection.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 291 | + | ||||
| 292 | + | frame := m.Render(s, renderer.DefaultStyle(), selection) | |||
| 293 | + | ||||
| 294 | + | got := frame.At(0, 0).Style | |||
| 295 | + | if got.Inverse || !got.HasBackgroundRGB || got.BackgroundRGB != selection.BackgroundRGB { | |||
| 296 | + | t.Fatalf("selected cell style = %+v, want themed selection %+v", got, selection) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 268 | func frameText(row []renderer.Cell) string { | 300 | func frameText(row []renderer.Cell) string { | ||
| 269 | var b strings.Builder | 301 | var b strings.Builder | ||
| 270 | for _, c := range row { | 302 | for _, c := range row { | ||
M internal/usecase/daemon/client.go +81 -0
| @@ -38,6 +38,7 @@ import ( | |||
| 38 | 38 | "github.com/bnema/vev/internal/usecase/palette" | |
| 39 | 39 | "github.com/bnema/vev/internal/usecase/picker" | |
| 40 | 40 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 41 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 41 | 42 | "github.com/bnema/vev/pkg/renderer" | |
| 42 | 43 | ) | |
| 43 | 44 | ||
| @@ -71,6 +72,8 @@ type attachedClient struct { | |||
| 71 | 72 | promptSubmit func(string) error | |
| 72 | 73 | promptPending []byte | |
| 73 | 74 | mouseScan mouse.Scanner | |
| 75 | + | themeMu sync.Mutex | |
| 76 | + | theme themeui.Theme | |
| 74 | 77 | lastCursor cursorOut | |
| 75 | 78 | sendMu sync.Mutex | |
| 76 | 79 | } | |
| @@ -117,6 +120,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | |||
| 117 | 120 | ||
| 118 | 121 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | |
| 119 | 122 | ||
| 123 | + | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 124 | + | ac.themeMu.Lock() | |
| 125 | + | defer ac.themeMu.Unlock() | |
| 126 | + | return ac.theme | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (ac *attachedClient) setTheme(t themeui.Theme) { | |
| 130 | + | ac.themeMu.Lock() | |
| 131 | + | ac.theme = t | |
| 132 | + | ac.themeMu.Unlock() | |
| 133 | + | } | |
| 134 | + | ||
| 120 | 135 | func (ac *attachedClient) copyModeActive() bool { | |
| 121 | 136 | ac.copyMu.Lock() | |
| 122 | 137 | defer ac.copyMu.Unlock() | |
| @@ -234,10 +249,42 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 234 | 249 | sess.mu.Lock() | |
| 235 | 250 | old := sess.client | |
| 236 | 251 | sess.client = ac | |
| 252 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 237 | 253 | sess.mu.Unlock() | |
| 254 | + | for _, tb := range tabs { | |
| 255 | + | tb.mu.Lock() | |
| 256 | + | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 257 | + | tb.mu.Unlock() | |
| 258 | + | } | |
| 238 | 259 | return ac, old | |
| 239 | 260 | } | |
| 240 | 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 | + | ||
| 241 | 288 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| 242 | 289 | if old == nil { | |
| 243 | 290 | return | |
| @@ -294,6 +341,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 294 | 341 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | |
| 295 | 342 | d.resize(sess, ac, rz.Size) | |
| 296 | 343 | } | |
| 344 | + | case ports.MsgTheme: | |
| 345 | + | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 346 | + | d.applyTheme(sess, ac, th) | |
| 347 | + | } | |
| 297 | 348 | case ports.MsgDetach: | |
| 298 | 349 | d.clientGone(sess, ac, true) | |
| 299 | 350 | return | |
| @@ -314,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 314 | 365 | return // already displaced by a newer client; nothing to do | |
| 315 | 366 | } | |
| 316 | 367 | d.unregisterPreview(ac) | |
| 368 | + | d.resetScreenDefaultColors(sess) | |
| 317 | 369 | if explicit { | |
| 318 | 370 | // Synchronous so the ack is delivered before the transport closes | |
| 319 | 371 | // (the client is actively awaiting it), but deadline-bounded so a | |
| @@ -329,6 +381,34 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 329 | 381 | ||
| 330 | 382 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 331 | 383 | ||
| 384 | + | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 385 | + | t := themeui.Theme{ | |
| 386 | + | Foreground: msg.Foreground, | |
| 387 | + | Background: msg.Background, | |
| 388 | + | HasFG: msg.HasForeground, | |
| 389 | + | HasBG: msg.HasBackground, | |
| 390 | + | TrueColor: msg.TrueColor, | |
| 391 | + | Known: msg.HasForeground && msg.HasBackground, | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | knownDefaultColors := t.HasFG && t.HasBG | |
| 395 | + | sess.mu.Lock() | |
| 396 | + | if sess.client != ac { | |
| 397 | + | sess.mu.Unlock() | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 401 | + | sess.mu.Unlock() | |
| 402 | + | ||
| 403 | + | ac.setTheme(t) | |
| 404 | + | for _, tb := range tabs { | |
| 405 | + | tb.mu.Lock() | |
| 406 | + | tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |
| 407 | + | tb.mu.Unlock() | |
| 408 | + | } | |
| 409 | + | d.paint(sess, ac, true) | |
| 410 | + | } | |
| 411 | + | ||
| 332 | 412 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |
| 333 | 413 | if !sz.Valid() { | |
| 334 | 414 | return | |
| @@ -361,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 361 | 441 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |
| 362 | 442 | if sess.detachIfCurrent(ac) { | |
| 363 | 443 | d.unregisterPreview(ac) | |
| 444 | + | d.resetScreenDefaultColors(sess) | |
| 364 | 445 | _ = ac.tr.Close() | |
| 365 | 446 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 366 | 447 | if sess.ephemeral { | |
| @@ -38,6 +38,7 @@ import ( | |||||
| 38 | "github.com/bnema/vev/internal/usecase/palette" | 38 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 39 | "github.com/bnema/vev/internal/usecase/picker" | 39 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 40 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 40 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 41 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 41 | "github.com/bnema/vev/pkg/renderer" | 42 | "github.com/bnema/vev/pkg/renderer" | ||
| 42 | ) | 43 | ) | ||
| 43 | 44 | ||||
| @@ -71,6 +72,8 @@ type attachedClient struct { | |||||
| 71 | promptSubmit func(string) error | 72 | promptSubmit func(string) error | ||
| 72 | promptPending []byte | 73 | promptPending []byte | ||
| 73 | mouseScan mouse.Scanner | 74 | mouseScan mouse.Scanner | ||
| 75 | + | themeMu sync.Mutex | |||
| 76 | + | theme themeui.Theme | |||
| 74 | lastCursor cursorOut | 77 | lastCursor cursorOut | ||
| 75 | sendMu sync.Mutex | 78 | sendMu sync.Mutex | ||
| 76 | } | 79 | } | ||
| @@ -117,6 +120,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | |||||
| 117 | 120 | ||||
| 118 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | 121 | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | ||
| 119 | 122 | ||||
| 123 | + | func (ac *attachedClient) getTheme() themeui.Theme { | |||
| 124 | + | ac.themeMu.Lock() | |||
| 125 | + | defer ac.themeMu.Unlock() | |||
| 126 | + | return ac.theme | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (ac *attachedClient) setTheme(t themeui.Theme) { | |||
| 130 | + | ac.themeMu.Lock() | |||
| 131 | + | ac.theme = t | |||
| 132 | + | ac.themeMu.Unlock() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 120 | func (ac *attachedClient) copyModeActive() bool { | 135 | func (ac *attachedClient) copyModeActive() bool { | ||
| 121 | ac.copyMu.Lock() | 136 | ac.copyMu.Lock() | ||
| 122 | defer ac.copyMu.Unlock() | 137 | defer ac.copyMu.Unlock() | ||
| @@ -234,10 +249,42 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 234 | sess.mu.Lock() | 249 | sess.mu.Lock() | ||
| 235 | old := sess.client | 250 | old := sess.client | ||
| 236 | sess.client = ac | 251 | sess.client = ac | ||
| 252 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 237 | sess.mu.Unlock() | 253 | sess.mu.Unlock() | ||
| 254 | + | for _, tb := range tabs { | |||
| 255 | + | tb.mu.Lock() | |||
| 256 | + | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 257 | + | tb.mu.Unlock() | |||
| 258 | + | } | |||
| 238 | return ac, old | 259 | return ac, old | ||
| 239 | } | 260 | } | ||
| 240 | 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 | + | ||||
| 241 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 288 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | ||
| 242 | if old == nil { | 289 | if old == nil { | ||
| 243 | return | 290 | return | ||
| @@ -294,6 +341,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 294 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | 341 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | ||
| 295 | d.resize(sess, ac, rz.Size) | 342 | d.resize(sess, ac, rz.Size) | ||
| 296 | } | 343 | } | ||
| 344 | + | case ports.MsgTheme: | |||
| 345 | + | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |||
| 346 | + | d.applyTheme(sess, ac, th) | |||
| 347 | + | } | |||
| 297 | case ports.MsgDetach: | 348 | case ports.MsgDetach: | ||
| 298 | d.clientGone(sess, ac, true) | 349 | d.clientGone(sess, ac, true) | ||
| 299 | return | 350 | return | ||
| @@ -314,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 314 | return // already displaced by a newer client; nothing to do | 365 | return // already displaced by a newer client; nothing to do | ||
| 315 | } | 366 | } | ||
| 316 | d.unregisterPreview(ac) | 367 | d.unregisterPreview(ac) | ||
| 368 | + | d.resetScreenDefaultColors(sess) | |||
| 317 | if explicit { | 369 | if explicit { | ||
| 318 | // Synchronous so the ack is delivered before the transport closes | 370 | // Synchronous so the ack is delivered before the transport closes | ||
| 319 | // (the client is actively awaiting it), but deadline-bounded so a | 371 | // (the client is actively awaiting it), but deadline-bounded so a | ||
| @@ -329,6 +381,34 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 329 | 381 | ||||
| 330 | // detachIfCurrent clears the client iff ac is the current one, reporting | 382 | // detachIfCurrent clears the client iff ac is the current one, reporting | ||
| 331 | 383 | ||||
| 384 | + | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |||
| 385 | + | t := themeui.Theme{ | |||
| 386 | + | Foreground: msg.Foreground, | |||
| 387 | + | Background: msg.Background, | |||
| 388 | + | HasFG: msg.HasForeground, | |||
| 389 | + | HasBG: msg.HasBackground, | |||
| 390 | + | TrueColor: msg.TrueColor, | |||
| 391 | + | Known: msg.HasForeground && msg.HasBackground, | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | knownDefaultColors := t.HasFG && t.HasBG | |||
| 395 | + | sess.mu.Lock() | |||
| 396 | + | if sess.client != ac { | |||
| 397 | + | sess.mu.Unlock() | |||
| 398 | + | return | |||
| 399 | + | } | |||
| 400 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 401 | + | sess.mu.Unlock() | |||
| 402 | + | ||||
| 403 | + | ac.setTheme(t) | |||
| 404 | + | for _, tb := range tabs { | |||
| 405 | + | tb.mu.Lock() | |||
| 406 | + | tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |||
| 407 | + | tb.mu.Unlock() | |||
| 408 | + | } | |||
| 409 | + | d.paint(sess, ac, true) | |||
| 410 | + | } | |||
| 411 | + | ||||
| 332 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | 412 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | ||
| 333 | if !sz.Valid() { | 413 | if !sz.Valid() { | ||
| 334 | return | 414 | return | ||
| @@ -361,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 361 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | 441 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | ||
| 362 | if sess.detachIfCurrent(ac) { | 442 | if sess.detachIfCurrent(ac) { | ||
| 363 | d.unregisterPreview(ac) | 443 | d.unregisterPreview(ac) | ||
| 444 | + | d.resetScreenDefaultColors(sess) | |||
| 364 | _ = ac.tr.Close() | 445 | _ = ac.tr.Close() | ||
| 365 | d.log.Warn("detached client after send error", "session", sess.name) | 446 | d.log.Warn("detached client after send error", "session", sess.name) | ||
| 366 | if sess.ephemeral { | 447 | if sess.ephemeral { | ||
M internal/usecase/daemon/copymode.go +2 -2
| @@ -307,8 +307,8 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 307 | 307 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | - | func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) { | |
| 310 | + | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) { | |
| 311 | 311 | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 312 | - | frame := mode.Render(snap) | |
| 312 | + | frame := mode.Render(snap, copyStatusStyle, selectionStyle) | |
| 313 | 313 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 314 | 314 | } | |
| @@ -307,8 +307,8 @@ func isCopyEscapePrefix(data []byte) bool { | |||||
| 307 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | 307 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | ||
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | - | func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) { | 310 | + | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) { |
| 311 | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 311 | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | ||
| 312 | - | frame := mode.Render(snap) | 312 | + | frame := mode.Render(snap, copyStatusStyle, selectionStyle) |
| 313 | return frame, []renderer.Damage{renderer.FullRedraw()} | 313 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 314 | } | 314 | } | ||
M internal/usecase/daemon/palette.go +4 -3
| @@ -145,9 +145,10 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 145 | 145 | return nil | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 149 | - | inner := paletteModal.Composite(base, renderer.DefaultStyle()) | |
| 150 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 148 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 149 | + | styleSet := resolveThemeStyles(styles) | |
| 150 | + | inner := paletteModal.Composite(base, styleSet.border) | |
| 151 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection) | |
| 151 | 152 | for y := range min(inner.Height, modalFrame.Height) { | |
| 152 | 153 | for x := range min(inner.Width, modalFrame.Width) { | |
| 153 | 154 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| @@ -145,9 +145,10 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 145 | return nil | 145 | return nil | ||
| 146 | } | 146 | } | ||
| 147 | 147 | ||||
| 148 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | 148 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { |
| 149 | - | inner := paletteModal.Composite(base, renderer.DefaultStyle()) | 149 | + | styleSet := resolveThemeStyles(styles) |
| 150 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 150 | + | inner := paletteModal.Composite(base, styleSet.border) |
| 151 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection) | |||
| 151 | for y := range min(inner.Height, modalFrame.Height) { | 152 | for y := range min(inner.Height, modalFrame.Height) { | ||
| 152 | for x := range min(inner.Width, modalFrame.Width) { | 153 | for x := range min(inner.Width, modalFrame.Width) { | ||
| 153 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | 154 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | ||
M internal/usecase/daemon/picker.go +4 -3
| @@ -363,9 +363,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 363 | 363 | return old | |
| 364 | 364 | } | |
| 365 | 365 | ||
| 366 | - | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 367 | - | inner := pickerModal.Composite(base, renderer.DefaultStyle()) | |
| 368 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview) | |
| 366 | + | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 367 | + | styleSet := resolveThemeStyles(styles) | |
| 368 | + | inner := pickerModal.Composite(base, styleSet.border) | |
| 369 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection) | |
| 369 | 370 | for y := range min(inner.Height, modalFrame.Height) { | |
| 370 | 371 | for x := range min(inner.Width, modalFrame.Width) { | |
| 371 | 372 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| @@ -363,9 +363,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 363 | return old | 363 | return old | ||
| 364 | } | 364 | } | ||
| 365 | 365 | ||||
| 366 | - | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | 366 | + | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { |
| 367 | - | inner := pickerModal.Composite(base, renderer.DefaultStyle()) | 367 | + | styleSet := resolveThemeStyles(styles) |
| 368 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview) | 368 | + | inner := pickerModal.Composite(base, styleSet.border) |
| 369 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection) | |||
| 369 | for y := range min(inner.Height, modalFrame.Height) { | 370 | for y := range min(inner.Height, modalFrame.Height) { | ||
| 370 | for x := range min(inner.Width, modalFrame.Width) { | 371 | for x := range min(inner.Width, modalFrame.Width) { | ||
| 371 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | 372 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | ||
M internal/usecase/daemon/prompt.go +4 -3
| @@ -92,9 +92,10 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 92 | 92 | } | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | - | func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 96 | - | inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle()) | |
| 97 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 95 | + | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 96 | + | styleSet := resolveThemeStyles(styles) | |
| 97 | + | inner := promptModalFor(model.Title()).Composite(base, styleSet.border) | |
| 98 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent) | |
| 98 | 99 | for y := range min(inner.Height, modalFrame.Height) { | |
| 99 | 100 | for x := range min(inner.Width, modalFrame.Width) { | |
| 100 | 101 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| @@ -92,9 +92,10 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||||
| 92 | } | 92 | } | ||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | - | func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | 95 | + | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { |
| 96 | - | inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle()) | 96 | + | styleSet := resolveThemeStyles(styles) |
| 97 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 97 | + | inner := promptModalFor(model.Title()).Composite(base, styleSet.border) |
| 98 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent) | |||
| 98 | for y := range min(inner.Height, modalFrame.Height) { | 99 | for y := range min(inner.Height, modalFrame.Height) { | ||
| 99 | for x := range min(inner.Width, modalFrame.Width) { | 100 | for x := range min(inner.Width, modalFrame.Width) { | ||
| 100 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | 101 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | ||
M internal/usecase/daemon/render.go +36 -7
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | "time" | |
| 32 | 32 | ||
| 33 | 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 34 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 34 | 35 | "github.com/bnema/vev/pkg/renderer" | |
| 35 | 36 | "github.com/bnema/vev/pkg/vt" | |
| 36 | 37 | ) | |
| @@ -273,6 +274,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 273 | 274 | ac.promptMu.Unlock() | |
| 274 | 275 | } | |
| 275 | 276 | ||
| 277 | + | styles := newThemeStyles(ac.getTheme()) | |
| 278 | + | ||
| 276 | 279 | tb.mu.Lock() | |
| 277 | 280 | if reset || copyActive || pickerActive || paletteActive || promptActive { | |
| 278 | 281 | ac.rend.Reset() | |
| @@ -280,22 +283,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 280 | 283 | if reset || pickerActive || paletteActive || promptActive { | |
| 281 | 284 | ac.lastCursor.valid = false | |
| 282 | 285 | } | |
| 283 | - | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | |
| 286 | + | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles) | |
| 284 | 287 | if copyActive { | |
| 285 | - | frame, damage = composeCopyClientFrame(copyMode, tb) | |
| 288 | + | frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection) | |
| 286 | 289 | } | |
| 287 | 290 | if pickerActive { | |
| 288 | 291 | if previewTab == tb { | |
| 289 | 292 | preview = pickerPreviewFromLockedTab(tb) | |
| 290 | 293 | } | |
| 291 | - | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | |
| 294 | + | frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles) | |
| 292 | 295 | } | |
| 293 | 296 | if paletteActive { | |
| 294 | - | frame, damage = composePaletteClientFrame(paletteModel, frame) | |
| 297 | + | frame, damage = composePaletteClientFrame(paletteModel, frame, styles) | |
| 295 | 298 | ac.paletteMu.Unlock() | |
| 296 | 299 | } | |
| 297 | 300 | if promptActive { | |
| 298 | - | frame, damage = composePromptClientFrame(promptModel, frame) | |
| 301 | + | frame, damage = composePromptClientFrame(promptModel, frame, styles) | |
| 299 | 302 | ac.promptMu.Unlock() | |
| 300 | 303 | } | |
| 301 | 304 | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive) | |
| @@ -364,13 +367,39 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||
| 364 | 367 | return b | |
| 365 | 368 | } | |
| 366 | 369 | ||
| 367 | - | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) { | |
| 370 | + | type themeStyles struct { | |
| 371 | + | statusBar renderer.Style | |
| 372 | + | accent renderer.Style | |
| 373 | + | border renderer.Style | |
| 374 | + | selection renderer.Style | |
| 375 | + | copyStatus renderer.Style | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func newThemeStyles(t themeui.Theme) themeStyles { | |
| 379 | + | return themeStyles{ | |
| 380 | + | statusBar: themeui.StatusBarStyle(t), | |
| 381 | + | accent: themeui.AccentStyle(t), | |
| 382 | + | border: themeui.BorderStyle(t), | |
| 383 | + | selection: themeui.SelectionStyle(t), | |
| 384 | + | copyStatus: themeui.SelectionStyle(t), | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func resolveThemeStyles(styles []themeStyles) themeStyles { | |
| 389 | + | if len(styles) > 0 { | |
| 390 | + | return styles[0] | |
| 391 | + | } | |
| 392 | + | return newThemeStyles(themeui.Theme{}) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 368 | 396 | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 369 | 397 | frame := renderer.NewFrame(width, screenRows+1) | |
| 370 | 398 | for y := range screenRows { | |
| 371 | 399 | copy(frame.Row(y), tb.screen.Frame.Row(y)) | |
| 372 | 400 | } | |
| 373 | - | drawStatus(frame.Row(screenRows), sess, rightStatus) | |
| 401 | + | styleSet := resolveThemeStyles(styles) | |
| 402 | + | drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet) | |
| 374 | 403 | if full { | |
| 375 | 404 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 376 | 405 | } | |
| @@ -31,6 +31,7 @@ import ( | |||||
| 31 | "time" | 31 | "time" | ||
| 32 | 32 | ||||
| 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 34 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 34 | "github.com/bnema/vev/pkg/renderer" | 35 | "github.com/bnema/vev/pkg/renderer" | ||
| 35 | "github.com/bnema/vev/pkg/vt" | 36 | "github.com/bnema/vev/pkg/vt" | ||
| 36 | ) | 37 | ) | ||
| @@ -273,6 +274,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 273 | ac.promptMu.Unlock() | 274 | ac.promptMu.Unlock() | ||
| 274 | } | 275 | } | ||
| 275 | 276 | ||||
| 277 | + | styles := newThemeStyles(ac.getTheme()) | |||
| 278 | + | ||||
| 276 | tb.mu.Lock() | 279 | tb.mu.Lock() | ||
| 277 | if reset || copyActive || pickerActive || paletteActive || promptActive { | 280 | if reset || copyActive || pickerActive || paletteActive || promptActive { | ||
| 278 | ac.rend.Reset() | 281 | ac.rend.Reset() | ||
| @@ -280,22 +283,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 280 | if reset || pickerActive || paletteActive || promptActive { | 283 | if reset || pickerActive || paletteActive || promptActive { | ||
| 281 | ac.lastCursor.valid = false | 284 | ac.lastCursor.valid = false | ||
| 282 | } | 285 | } | ||
| 283 | - | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | 286 | + | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles) |
| 284 | if copyActive { | 287 | if copyActive { | ||
| 285 | - | frame, damage = composeCopyClientFrame(copyMode, tb) | 288 | + | frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection) |
| 286 | } | 289 | } | ||
| 287 | if pickerActive { | 290 | if pickerActive { | ||
| 288 | if previewTab == tb { | 291 | if previewTab == tb { | ||
| 289 | preview = pickerPreviewFromLockedTab(tb) | 292 | preview = pickerPreviewFromLockedTab(tb) | ||
| 290 | } | 293 | } | ||
| 291 | - | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | 294 | + | frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles) |
| 292 | } | 295 | } | ||
| 293 | if paletteActive { | 296 | if paletteActive { | ||
| 294 | - | frame, damage = composePaletteClientFrame(paletteModel, frame) | 297 | + | frame, damage = composePaletteClientFrame(paletteModel, frame, styles) |
| 295 | ac.paletteMu.Unlock() | 298 | ac.paletteMu.Unlock() | ||
| 296 | } | 299 | } | ||
| 297 | if promptActive { | 300 | if promptActive { | ||
| 298 | - | frame, damage = composePromptClientFrame(promptModel, frame) | 301 | + | frame, damage = composePromptClientFrame(promptModel, frame, styles) |
| 299 | ac.promptMu.Unlock() | 302 | ac.promptMu.Unlock() | ||
| 300 | } | 303 | } | ||
| 301 | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive) | 304 | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive) | ||
| @@ -364,13 +367,39 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||||
| 364 | return b | 367 | return b | ||
| 365 | } | 368 | } | ||
| 366 | 369 | ||||
| 367 | - | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) { | 370 | + | type themeStyles struct { |
| 371 | + | statusBar renderer.Style | |||
| 372 | + | accent renderer.Style | |||
| 373 | + | border renderer.Style | |||
| 374 | + | selection renderer.Style | |||
| 375 | + | copyStatus renderer.Style | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func newThemeStyles(t themeui.Theme) themeStyles { | |||
| 379 | + | return themeStyles{ | |||
| 380 | + | statusBar: themeui.StatusBarStyle(t), | |||
| 381 | + | accent: themeui.AccentStyle(t), | |||
| 382 | + | border: themeui.BorderStyle(t), | |||
| 383 | + | selection: themeui.SelectionStyle(t), | |||
| 384 | + | copyStatus: themeui.SelectionStyle(t), | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func resolveThemeStyles(styles []themeStyles) themeStyles { | |||
| 389 | + | if len(styles) > 0 { | |||
| 390 | + | return styles[0] | |||
| 391 | + | } | |||
| 392 | + | return newThemeStyles(themeui.Theme{}) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 368 | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | 396 | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | ||
| 369 | frame := renderer.NewFrame(width, screenRows+1) | 397 | frame := renderer.NewFrame(width, screenRows+1) | ||
| 370 | for y := range screenRows { | 398 | for y := range screenRows { | ||
| 371 | copy(frame.Row(y), tb.screen.Frame.Row(y)) | 399 | copy(frame.Row(y), tb.screen.Frame.Row(y)) | ||
| 372 | } | 400 | } | ||
| 373 | - | drawStatus(frame.Row(screenRows), sess, rightStatus) | 401 | + | styleSet := resolveThemeStyles(styles) |
| 402 | + | drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet) | |||
| 374 | if full { | 403 | if full { | ||
| 375 | return frame, []renderer.Damage{renderer.FullRedraw()} | 404 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 376 | } | 405 | } | ||
M internal/usecase/daemon/session.go +5 -0
| @@ -110,12 +110,17 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 110 | 110 | sess.mu.Lock() | |
| 111 | 111 | name := sess.name | |
| 112 | 112 | cwd := sess.cwd | |
| 113 | + | client := sess.client | |
| 113 | 114 | sess.mu.Unlock() | |
| 114 | 115 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 115 | 116 | if err != nil { | |
| 116 | 117 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| 117 | 118 | } | |
| 118 | 119 | tb := newTab(pty, tbSize) | |
| 120 | + | if client != nil { | |
| 121 | + | t := client.getTheme() | |
| 122 | + | tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |
| 123 | + | } | |
| 119 | 124 | d.mu.Lock() | |
| 120 | 125 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | |
| 121 | 126 | d.mu.Unlock() | |
| @@ -110,12 +110,17 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 110 | sess.mu.Lock() | 110 | sess.mu.Lock() | ||
| 111 | name := sess.name | 111 | name := sess.name | ||
| 112 | cwd := sess.cwd | 112 | cwd := sess.cwd | ||
| 113 | + | client := sess.client | |||
| 113 | sess.mu.Unlock() | 114 | sess.mu.Unlock() | ||
| 114 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | 115 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | ||
| 115 | if err != nil { | 116 | if err != nil { | ||
| 116 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | 117 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | ||
| 117 | } | 118 | } | ||
| 118 | tb := newTab(pty, tbSize) | 119 | tb := newTab(pty, tbSize) | ||
| 120 | + | if client != nil { | |||
| 121 | + | t := client.getTheme() | |||
| 122 | + | tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |||
| 123 | + | } | |||
| 119 | d.mu.Lock() | 124 | d.mu.Lock() | ||
| 120 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | 125 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | ||
| 121 | d.mu.Unlock() | 126 | d.mu.Unlock() | ||
M internal/usecase/daemon/status.go +6 -5
| @@ -31,24 +31,25 @@ import ( | |||
| 31 | 31 | "github.com/bnema/vev/pkg/renderer" | |
| 32 | 32 | ) | |
| 33 | 33 | ||
| 34 | - | func drawStatus(row []renderer.Cell, sess *session, rightText string) { | |
| 34 | + | func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) { | |
| 35 | 35 | for i := range row { | |
| 36 | 36 | row[i] = renderer.BlankCell() | |
| 37 | 37 | } | |
| 38 | + | styleSet := resolveThemeStyles(styles) | |
| 38 | 39 | status := sess.statusSegments() | |
| 39 | 40 | x := 0 | |
| 40 | - | writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle()) | |
| 41 | + | writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar) | |
| 41 | 42 | for _, w := range status.tabs { | |
| 42 | - | style := renderer.DefaultStyle() | |
| 43 | + | style := styleSet.statusBar | |
| 43 | 44 | if w.active { | |
| 44 | - | style.Inverse = true | |
| 45 | + | style = styleSet.accent | |
| 45 | 46 | } | |
| 46 | 47 | writeStatusText(row, &x, " "+w.name+" ", style) | |
| 47 | 48 | } | |
| 48 | 49 | if rightText == "" { | |
| 49 | 50 | return | |
| 50 | 51 | } | |
| 51 | - | style := renderer.DefaultStyle() | |
| 52 | + | style := styleSet.statusBar | |
| 52 | 53 | rightWidth := len([]rune(rightText)) + 1 | |
| 53 | 54 | if len(row)-x-1 < rightWidth { | |
| 54 | 55 | return | |
| @@ -31,24 +31,25 @@ import ( | |||||
| 31 | "github.com/bnema/vev/pkg/renderer" | 31 | "github.com/bnema/vev/pkg/renderer" | ||
| 32 | ) | 32 | ) | ||
| 33 | 33 | ||||
| 34 | - | func drawStatus(row []renderer.Cell, sess *session, rightText string) { | 34 | + | func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) { |
| 35 | for i := range row { | 35 | for i := range row { | ||
| 36 | row[i] = renderer.BlankCell() | 36 | row[i] = renderer.BlankCell() | ||
| 37 | } | 37 | } | ||
| 38 | + | styleSet := resolveThemeStyles(styles) | |||
| 38 | status := sess.statusSegments() | 39 | status := sess.statusSegments() | ||
| 39 | x := 0 | 40 | x := 0 | ||
| 40 | - | writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle()) | 41 | + | writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar) |
| 41 | for _, w := range status.tabs { | 42 | for _, w := range status.tabs { | ||
| 42 | - | style := renderer.DefaultStyle() | 43 | + | style := styleSet.statusBar |
| 43 | if w.active { | 44 | if w.active { | ||
| 44 | - | style.Inverse = true | 45 | + | style = styleSet.accent |
| 45 | } | 46 | } | ||
| 46 | writeStatusText(row, &x, " "+w.name+" ", style) | 47 | writeStatusText(row, &x, " "+w.name+" ", style) | ||
| 47 | } | 48 | } | ||
| 48 | if rightText == "" { | 49 | if rightText == "" { | ||
| 49 | return | 50 | return | ||
| 50 | } | 51 | } | ||
| 51 | - | style := renderer.DefaultStyle() | 52 | + | style := styleSet.statusBar |
| 52 | rightWidth := len([]rune(rightText)) + 1 | 53 | rightWidth := len([]rune(rightText)) + 1 | ||
| 53 | if len(row)-x-1 < rightWidth { | 54 | if len(row)-x-1 < rightWidth { | ||
| 54 | return | 55 | return | ||
M internal/usecase/daemon/status_test.go +159 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 4 | 5 | "sync" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -8,6 +9,7 @@ import ( | |||
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 14 | "github.com/bnema/vev/pkg/vt" | |
| 13 | 15 | ) | |
| @@ -45,6 +47,163 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 45 | 47 | } | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 50 | + | func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |
| 51 | + | p, release := newBlockingPTY(t) | |
| 52 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 53 | + | defer release() | |
| 54 | + | win := sess.activeTab() | |
| 55 | + | win.screen = vt.NewScreen(12, 2) | |
| 56 | + | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 57 | + | ac.setTheme(themeui.Theme{ | |
| 58 | + | Foreground: renderer.RGB{R: 220, G: 220, B: 220}, | |
| 59 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 60 | + | HasFG: true, | |
| 61 | + | HasBG: true, | |
| 62 | + | TrueColor: true, | |
| 63 | + | Known: true, | |
| 64 | + | }) | |
| 65 | + | ||
| 66 | + | d := newThemeStyles(ac.getTheme()) | |
| 67 | + | frame, damage := composeClientFrame(sess, win, true, "", d) | |
| 68 | + | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | |
| 69 | + | ||
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.Contains(t, string(out), ";48;2;") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |
| 75 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 76 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 77 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 78 | + | defer releasePTY1() | |
| 79 | + | defer releasePTY2() | |
| 80 | + | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}} | |
| 81 | + | ||
| 82 | + | d.applyTheme(sess, ac, msg) | |
| 83 | + | ||
| 84 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | |
| 85 | + | for _, tb := range sess.tabs { | |
| 86 | + | var got []byte | |
| 87 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 88 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 89 | + | require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got)) | |
| 90 | + | require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got)) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |
| 95 | + | p, release := newBlockingPTY(t) | |
| 96 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 97 | + | defer release() | |
| 98 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 99 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 100 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 101 | + | }) | |
| 102 | + | tr, _ := newCapturingTransport(t) | |
| 103 | + | ||
| 104 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 105 | + | ||
| 106 | + | var got []byte | |
| 107 | + | tb := sess.activeTab() | |
| 108 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 109 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 110 | + | require.Empty(t, got) | |
| 111 | + | } | |
| 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 | + | ||
| 188 | + | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |
| 189 | + | p, release := newBlockingPTY(t) | |
| 190 | + | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 191 | + | defer release() | |
| 192 | + | tr, _ := newCapturingTransport(t) | |
| 193 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 194 | + | ||
| 195 | + | d.applyTheme(sess, old, ports.Theme{ | |
| 196 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 197 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 198 | + | }) | |
| 199 | + | ||
| 200 | + | var got []byte | |
| 201 | + | tb := sess.activeTab() | |
| 202 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 203 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 204 | + | require.Empty(t, got) | |
| 205 | + | } | |
| 206 | + | ||
| 48 | 207 | func TestStatusMarksEphemeralSession(t *testing.T) { | |
| 49 | 208 | p, release := newBlockingPTY(t) | |
| 50 | 209 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "sync" | 5 | "sync" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| @@ -8,6 +9,7 @@ import ( | |||||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 11 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | "github.com/bnema/vev/pkg/vt" | 14 | "github.com/bnema/vev/pkg/vt" | ||
| 13 | ) | 15 | ) | ||
| @@ -45,6 +47,163 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 45 | } | 47 | } | ||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| 50 | + | func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 51 | + | p, release := newBlockingPTY(t) | |||
| 52 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 53 | + | defer release() | |||
| 54 | + | win := sess.activeTab() | |||
| 55 | + | win.screen = vt.NewScreen(12, 2) | |||
| 56 | + | win.size = domain.Size{Cols: 12, Rows: 2} | |||
| 57 | + | ac.setTheme(themeui.Theme{ | |||
| 58 | + | Foreground: renderer.RGB{R: 220, G: 220, B: 220}, | |||
| 59 | + | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 60 | + | HasFG: true, | |||
| 61 | + | HasBG: true, | |||
| 62 | + | TrueColor: true, | |||
| 63 | + | Known: true, | |||
| 64 | + | }) | |||
| 65 | + | ||||
| 66 | + | d := newThemeStyles(ac.getTheme()) | |||
| 67 | + | frame, damage := composeClientFrame(sess, win, true, "", d) | |||
| 68 | + | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | |||
| 69 | + | ||||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | require.Contains(t, string(out), ";48;2;") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |||
| 75 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 76 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 77 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 78 | + | defer releasePTY1() | |||
| 79 | + | defer releasePTY2() | |||
| 80 | + | msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}} | |||
| 81 | + | ||||
| 82 | + | d.applyTheme(sess, ac, msg) | |||
| 83 | + | ||||
| 84 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | |||
| 85 | + | for _, tb := range sess.tabs { | |||
| 86 | + | var got []byte | |||
| 87 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 88 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 89 | + | require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got)) | |||
| 90 | + | require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got)) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||
| 95 | + | p, release := newBlockingPTY(t) | |||
| 96 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 97 | + | defer release() | |||
| 98 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 99 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 100 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 101 | + | }) | |||
| 102 | + | tr, _ := newCapturingTransport(t) | |||
| 103 | + | ||||
| 104 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |||
| 105 | + | ||||
| 106 | + | var got []byte | |||
| 107 | + | tb := sess.activeTab() | |||
| 108 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 109 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 110 | + | require.Empty(t, got) | |||
| 111 | + | } | |||
| 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 | + | ||||
| 188 | + | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |||
| 189 | + | p, release := newBlockingPTY(t) | |||
| 190 | + | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |||
| 191 | + | defer release() | |||
| 192 | + | tr, _ := newCapturingTransport(t) | |||
| 193 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |||
| 194 | + | ||||
| 195 | + | d.applyTheme(sess, old, ports.Theme{ | |||
| 196 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 197 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 198 | + | }) | |||
| 199 | + | ||||
| 200 | + | var got []byte | |||
| 201 | + | tb := sess.activeTab() | |||
| 202 | + | tb.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 203 | + | tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 204 | + | require.Empty(t, got) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 48 | func TestStatusMarksEphemeralSession(t *testing.T) { | 207 | func TestStatusMarksEphemeralSession(t *testing.T) { | ||
| 49 | p, release := newBlockingPTY(t) | 208 | p, release := newBlockingPTY(t) | ||
| 50 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | 209 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/palette/model.go +10 -5
| @@ -96,18 +96,23 @@ func (m *Model) clamp() { | |||
| 96 | 96 | } | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | - | func (m *Model) Render(inner domain.Size) renderer.Frame { | |
| 99 | + | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | |
| 100 | 100 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 101 | 101 | if frame.Width == 0 || frame.Height == 0 { | |
| 102 | 102 | return frame | |
| 103 | 103 | } | |
| 104 | 104 | base := renderer.DefaultStyle() | |
| 105 | + | selection := base | |
| 106 | + | selection.Inverse = true | |
| 107 | + | if len(selectedStyle) > 0 { | |
| 108 | + | selection = selectedStyle[0] | |
| 109 | + | } | |
| 105 | 110 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 106 | 111 | if m == nil { | |
| 107 | - | ui.DrawInputLine(frame, 0, "> ", "", base) | |
| 112 | + | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| 108 | 113 | return frame | |
| 109 | 114 | } | |
| 110 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base) | |
| 115 | + | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 111 | 116 | visible := frame.Height - 1 | |
| 112 | 117 | if visible <= 0 { | |
| 113 | 118 | return frame | |
| @@ -135,7 +140,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||
| 135 | 140 | match := m.matches[idx] | |
| 136 | 141 | style := base | |
| 137 | 142 | if idx == m.selected { | |
| 138 | - | style.Inverse = true | |
| 143 | + | style = selection | |
| 139 | 144 | } | |
| 140 | 145 | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 141 | 146 | x := 0 | |
| @@ -146,8 +151,8 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||
| 146 | 151 | for i, r := range []rune(match.Command.Code) { | |
| 147 | 152 | cellStyle := style | |
| 148 | 153 | if highlight[i] { | |
| 154 | + | cellStyle = selection | |
| 149 | 155 | cellStyle.Bold = true | |
| 150 | - | cellStyle.Inverse = true | |
| 151 | 156 | } | |
| 152 | 157 | if x < frame.Width { | |
| 153 | 158 | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | |
| @@ -96,18 +96,23 @@ func (m *Model) clamp() { | |||||
| 96 | } | 96 | } | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | - | func (m *Model) Render(inner domain.Size) renderer.Frame { | 99 | + | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { |
| 100 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 100 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 101 | if frame.Width == 0 || frame.Height == 0 { | 101 | if frame.Width == 0 || frame.Height == 0 { | ||
| 102 | return frame | 102 | return frame | ||
| 103 | } | 103 | } | ||
| 104 | base := renderer.DefaultStyle() | 104 | base := renderer.DefaultStyle() | ||
| 105 | + | selection := base | |||
| 106 | + | selection.Inverse = true | |||
| 107 | + | if len(selectedStyle) > 0 { | |||
| 108 | + | selection = selectedStyle[0] | |||
| 109 | + | } | |||
| 105 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 110 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 106 | if m == nil { | 111 | if m == nil { | ||
| 107 | - | ui.DrawInputLine(frame, 0, "> ", "", base) | 112 | + | ui.DrawInputLine(frame, 0, "> ", "", base, selection) |
| 108 | return frame | 113 | return frame | ||
| 109 | } | 114 | } | ||
| 110 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base) | 115 | + | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) |
| 111 | visible := frame.Height - 1 | 116 | visible := frame.Height - 1 | ||
| 112 | if visible <= 0 { | 117 | if visible <= 0 { | ||
| 113 | return frame | 118 | return frame | ||
| @@ -135,7 +140,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||||
| 135 | match := m.matches[idx] | 140 | match := m.matches[idx] | ||
| 136 | style := base | 141 | style := base | ||
| 137 | if idx == m.selected { | 142 | if idx == m.selected { | ||
| 138 | - | style.Inverse = true | 143 | + | style = selection |
| 139 | } | 144 | } | ||
| 140 | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 145 | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | ||
| 141 | x := 0 | 146 | x := 0 | ||
| @@ -146,8 +151,8 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||||
| 146 | for i, r := range []rune(match.Command.Code) { | 151 | for i, r := range []rune(match.Command.Code) { | ||
| 147 | cellStyle := style | 152 | cellStyle := style | ||
| 148 | if highlight[i] { | 153 | if highlight[i] { | ||
| 154 | + | cellStyle = selection | |||
| 149 | cellStyle.Bold = true | 155 | cellStyle.Bold = true | ||
| 150 | - | cellStyle.Inverse = true | |||
| 151 | } | 156 | } | ||
| 152 | if x < frame.Width { | 157 | if x < frame.Width { | ||
| 153 | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | 158 | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | ||
M internal/usecase/palette/model_test.go +18 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -89,3 +90,20 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||
| 89 | 90 | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | |
| 90 | 91 | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | |
| 91 | 92 | } | |
| 93 | + | ||
| 94 | + | func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |
| 95 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 96 | + | m.Insert('c') | |
| 97 | + | m.Insert('y') | |
| 98 | + | accent := renderer.DefaultStyle() | |
| 99 | + | accent.HasBackgroundRGB = true | |
| 100 | + | accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 101 | + | ||
| 102 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent) | |
| 103 | + | ||
| 104 | + | matched := frame.At(0, 1).Style | |
| 105 | + | require.True(t, matched.Bold) | |
| 106 | + | require.False(t, matched.Inverse) | |
| 107 | + | require.True(t, matched.HasBackgroundRGB) | |
| 108 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB) | |
| 109 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -89,3 +90,20 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||||
| 89 | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | 90 | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | ||
| 90 | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | 91 | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | ||
| 91 | } | 92 | } | ||
| 93 | + | ||||
| 94 | + | func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |||
| 95 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |||
| 96 | + | m.Insert('c') | |||
| 97 | + | m.Insert('y') | |||
| 98 | + | accent := renderer.DefaultStyle() | |||
| 99 | + | accent.HasBackgroundRGB = true | |||
| 100 | + | accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 101 | + | ||||
| 102 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent) | |||
| 103 | + | ||||
| 104 | + | matched := frame.At(0, 1).Style | |||
| 105 | + | require.True(t, matched.Bold) | |||
| 106 | + | require.False(t, matched.Inverse) | |||
| 107 | + | require.True(t, matched.HasBackgroundRGB) | |||
| 108 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB) | |||
| 109 | + | } | |||
M internal/usecase/picker/model.go +9 -4
| @@ -134,10 +134,15 @@ func (m *Model) Clone() *Model { | |||
| 134 | 134 | return &clone | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | |
| 137 | + | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { | |
| 138 | 138 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 139 | 139 | layout := ChooseLayout(inner) | |
| 140 | - | m.renderList(frame, layout.List) | |
| 140 | + | selection := renderer.DefaultStyle() | |
| 141 | + | selection.Inverse = true | |
| 142 | + | if len(selectedStyle) > 0 { | |
| 143 | + | selection = selectedStyle[0] | |
| 144 | + | } | |
| 145 | + | m.renderList(frame, layout.List, selection) | |
| 141 | 146 | blitPreview(frame, layout.Preview, preview) | |
| 142 | 147 | return frame | |
| 143 | 148 | } | |
| @@ -167,7 +172,7 @@ func (m *Model) firstLeaf() int { | |||
| 167 | 172 | return -1 | |
| 168 | 173 | } | |
| 169 | 174 | ||
| 170 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |
| 175 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { | |
| 171 | 176 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 172 | 177 | return | |
| 173 | 178 | } | |
| @@ -181,7 +186,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||
| 181 | 186 | r := m.rows[idx] | |
| 182 | 187 | style := renderer.DefaultStyle() | |
| 183 | 188 | if idx == m.selected { | |
| 184 | - | style.Inverse = true | |
| 189 | + | style = selection | |
| 185 | 190 | } | |
| 186 | 191 | label := r.label | |
| 187 | 192 | if !r.header { | |
| @@ -134,10 +134,15 @@ func (m *Model) Clone() *Model { | |||||
| 134 | return &clone | 134 | return &clone | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | - | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | 137 | + | func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame { |
| 138 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 138 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 139 | layout := ChooseLayout(inner) | 139 | layout := ChooseLayout(inner) | ||
| 140 | - | m.renderList(frame, layout.List) | 140 | + | selection := renderer.DefaultStyle() |
| 141 | + | selection.Inverse = true | |||
| 142 | + | if len(selectedStyle) > 0 { | |||
| 143 | + | selection = selectedStyle[0] | |||
| 144 | + | } | |||
| 145 | + | m.renderList(frame, layout.List, selection) | |||
| 141 | blitPreview(frame, layout.Preview, preview) | 146 | blitPreview(frame, layout.Preview, preview) | ||
| 142 | return frame | 147 | return frame | ||
| 143 | } | 148 | } | ||
| @@ -167,7 +172,7 @@ func (m *Model) firstLeaf() int { | |||||
| 167 | return -1 | 172 | return -1 | ||
| 168 | } | 173 | } | ||
| 169 | 174 | ||||
| 170 | - | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | 175 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) { |
| 171 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 176 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 172 | return | 177 | return | ||
| 173 | } | 178 | } | ||
| @@ -181,7 +186,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||||
| 181 | r := m.rows[idx] | 186 | r := m.rows[idx] | ||
| 182 | style := renderer.DefaultStyle() | 187 | style := renderer.DefaultStyle() | ||
| 183 | if idx == m.selected { | 188 | if idx == m.selected { | ||
| 184 | - | style.Inverse = true | 189 | + | style = selection |
| 185 | } | 190 | } | ||
| 186 | label := r.label | 191 | label := r.label | ||
| 187 | if !r.header { | 192 | if !r.header { | ||
M internal/usecase/prompt/model.go +8 -5
| @@ -54,18 +54,21 @@ func (m *Model) SetError(msg string) { | |||
| 54 | 54 | } | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | func (m *Model) Render(inner domain.Size) renderer.Frame { | |
| 57 | + | func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame { | |
| 58 | 58 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 59 | 59 | if frame.Width == 0 || frame.Height == 0 { | |
| 60 | 60 | return frame | |
| 61 | 61 | } | |
| 62 | 62 | base := renderer.DefaultStyle() | |
| 63 | + | accent := base | |
| 64 | + | accent.Inverse = true | |
| 65 | + | if len(accentStyle) > 0 { | |
| 66 | + | accent = accentStyle[0] | |
| 67 | + | } | |
| 63 | 68 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 64 | - | ui.DrawInputLine(frame, 0, "> ", m.Value(), base) | |
| 69 | + | ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent) | |
| 65 | 70 | if frame.Height > 1 && m != nil && m.errMsg != "" { | |
| 66 | - | errStyle := base | |
| 67 | - | errStyle.Inverse = true | |
| 68 | - | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, errStyle) | |
| 71 | + | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent) | |
| 69 | 72 | } | |
| 70 | 73 | return frame | |
| 71 | 74 | } | |
| @@ -54,18 +54,21 @@ func (m *Model) SetError(msg string) { | |||||
| 54 | } | 54 | } | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| 57 | - | func (m *Model) Render(inner domain.Size) renderer.Frame { | 57 | + | func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame { |
| 58 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 58 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 59 | if frame.Width == 0 || frame.Height == 0 { | 59 | if frame.Width == 0 || frame.Height == 0 { | ||
| 60 | return frame | 60 | return frame | ||
| 61 | } | 61 | } | ||
| 62 | base := renderer.DefaultStyle() | 62 | base := renderer.DefaultStyle() | ||
| 63 | + | accent := base | |||
| 64 | + | accent.Inverse = true | |||
| 65 | + | if len(accentStyle) > 0 { | |||
| 66 | + | accent = accentStyle[0] | |||
| 67 | + | } | |||
| 63 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 68 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 64 | - | ui.DrawInputLine(frame, 0, "> ", m.Value(), base) | 69 | + | ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent) |
| 65 | if frame.Height > 1 && m != nil && m.errMsg != "" { | 70 | if frame.Height > 1 && m != nil && m.errMsg != "" { | ||
| 66 | - | errStyle := base | 71 | + | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent) |
| 67 | - | errStyle.Inverse = true | |||
| 68 | - | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, errStyle) | |||
| 69 | } | 72 | } | ||
| 70 | return frame | 73 | return frame | ||
| 71 | } | 74 | } | ||
A internal/usecase/theme/theme.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const maxPending = 64 | |
| 12 | + | ||
| 13 | + | // Theme describes terminal default colors reported by OSC 10/11. | |
| 14 | + | type Theme struct { | |
| 15 | + | Foreground renderer.RGB | |
| 16 | + | Background renderer.RGB | |
| 17 | + | HasFG bool | |
| 18 | + | HasBG bool | |
| 19 | + | TrueColor bool | |
| 20 | + | Known bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11. | |
| 24 | + | func ParseXColor(s string) (renderer.RGB, bool) { | |
| 25 | + | if strings.HasPrefix(s, "#") { | |
| 26 | + | if len(s) != 7 { | |
| 27 | + | return renderer.RGB{}, false | |
| 28 | + | } | |
| 29 | + | r, ok := parseHexByte(s[1:3]) | |
| 30 | + | if !ok { | |
| 31 | + | return renderer.RGB{}, false | |
| 32 | + | } | |
| 33 | + | g, ok := parseHexByte(s[3:5]) | |
| 34 | + | if !ok { | |
| 35 | + | return renderer.RGB{}, false | |
| 36 | + | } | |
| 37 | + | b, ok := parseHexByte(s[5:7]) | |
| 38 | + | if !ok { | |
| 39 | + | return renderer.RGB{}, false | |
| 40 | + | } | |
| 41 | + | return renderer.RGB{R: r, G: g, B: b}, true | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | if !strings.HasPrefix(s, "rgb:") { | |
| 45 | + | return renderer.RGB{}, false | |
| 46 | + | } | |
| 47 | + | parts := strings.Split(s[len("rgb:"):], "/") | |
| 48 | + | if len(parts) != 3 { | |
| 49 | + | return renderer.RGB{}, false | |
| 50 | + | } | |
| 51 | + | width := len(parts[0]) | |
| 52 | + | if width != 2 && width != 4 { | |
| 53 | + | return renderer.RGB{}, false | |
| 54 | + | } | |
| 55 | + | var out [3]uint8 | |
| 56 | + | for i, part := range parts { | |
| 57 | + | if len(part) != width { | |
| 58 | + | return renderer.RGB{}, false | |
| 59 | + | } | |
| 60 | + | if width == 2 { | |
| 61 | + | v, ok := parseHexByte(part) | |
| 62 | + | if !ok { | |
| 63 | + | return renderer.RGB{}, false | |
| 64 | + | } | |
| 65 | + | out[i] = v | |
| 66 | + | continue | |
| 67 | + | } | |
| 68 | + | v, err := strconv.ParseUint(part, 16, 16) | |
| 69 | + | if err != nil { | |
| 70 | + | return renderer.RGB{}, false | |
| 71 | + | } | |
| 72 | + | out[i] = uint8(v >> 8) | |
| 73 | + | } | |
| 74 | + | return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func parseHexByte(s string) (uint8, bool) { | |
| 78 | + | v, err := strconv.ParseUint(s, 16, 8) | |
| 79 | + | if err != nil { | |
| 80 | + | return 0, false | |
| 81 | + | } | |
| 82 | + | return uint8(v), true | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Scanner decodes OSC 10/11 color responses while preserving ordinary bytes. | |
| 86 | + | type Scanner struct { | |
| 87 | + | pending []byte | |
| 88 | + | } | |
| 89 | + | ||
| 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, 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. | |
| 98 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | |
| 99 | + | if len(s.pending) > 0 { | |
| 100 | + | combined := make([]byte, 0, len(s.pending)+len(data)) | |
| 101 | + | combined = append(combined, s.pending...) | |
| 102 | + | combined = append(combined, data...) | |
| 103 | + | data = combined | |
| 104 | + | s.pending = nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | byteStart := 0 | |
| 108 | + | for i := 0; i < len(data); i++ { | |
| 109 | + | if data[i] != '\x1b' { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |
| 114 | + | if !completePrefix { | |
| 115 | + | if possiblePrefix { | |
| 116 | + | if byteStart < i { | |
| 117 | + | onBytes(data[byteStart:i]) | |
| 118 | + | } | |
| 119 | + | s.bufferOrFlush(data[i:], onBytes) | |
| 120 | + | return | |
| 121 | + | } | |
| 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. | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | if byteStart < i { | |
| 131 | + | onBytes(data[byteStart:i]) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | termStart, termEnd := findTerminator(data[i+5:]) | |
| 135 | + | if termStart < 0 { | |
| 136 | + | s.bufferOrFlush(data[i:], onBytes) | |
| 137 | + | return | |
| 138 | + | } | |
| 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])) | |
| 144 | + | if ok { | |
| 145 | + | onColor(kind, rgb) | |
| 146 | + | } else { | |
| 147 | + | onBytes(raw) | |
| 148 | + | } | |
| 149 | + | i = termEnd - 1 | |
| 150 | + | byteStart = termEnd | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | if byteStart < len(data) { | |
| 154 | + | onBytes(data[byteStart:]) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) { | |
| 159 | + | if len(partial) > maxPending { | |
| 160 | + | onBytes(partial) | |
| 161 | + | s.pending = nil | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | s.pending = append(s.pending, partial...) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |
| 168 | + | if len(data) < 2 { | |
| 169 | + | return false, false | |
| 170 | + | } | |
| 171 | + | prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")} | |
| 172 | + | for _, prefix := range prefixes { | |
| 173 | + | if len(data) >= len(prefix) { | |
| 174 | + | if bytes.Equal(data[:len(prefix)], prefix) { | |
| 175 | + | return true, true | |
| 176 | + | } | |
| 177 | + | continue | |
| 178 | + | } | |
| 179 | + | if bytes.Equal(data, prefix[:len(data)]) { | |
| 180 | + | return false, true | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | return false, false | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func findTerminator(data []byte) (start int, end int) { | |
| 187 | + | for i := 0; i < len(data); i++ { | |
| 188 | + | switch data[i] { | |
| 189 | + | case '\x07': | |
| 190 | + | return i, i + 1 | |
| 191 | + | case '\x1b': | |
| 192 | + | if i+1 < len(data) && data[i+1] == '\\' { | |
| 193 | + | return i, i + 2 | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return -1, -1 | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Blend linearly interpolates between a and b. | |
| 201 | + | func Blend(a, b renderer.RGB, t float64) renderer.RGB { | |
| 202 | + | if t < 0 { | |
| 203 | + | t = 0 | |
| 204 | + | } | |
| 205 | + | if t > 1 { | |
| 206 | + | t = 1 | |
| 207 | + | } | |
| 208 | + | return renderer.RGB{ | |
| 209 | + | R: blendByte(a.R, b.R, t), | |
| 210 | + | G: blendByte(a.G, b.G, t), | |
| 211 | + | B: blendByte(a.B, b.B, t), | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func blendByte(a, b uint8, t float64) uint8 { | |
| 216 | + | return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func StatusBarStyle(t Theme) renderer.Style { | |
| 220 | + | if !usable(t) { | |
| 221 | + | return renderer.DefaultStyle() | |
| 222 | + | } | |
| 223 | + | style := renderer.DefaultStyle() | |
| 224 | + | style.HasForegroundRGB = true | |
| 225 | + | style.ForegroundRGB = t.Foreground | |
| 226 | + | style.HasBackgroundRGB = true | |
| 227 | + | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12) | |
| 228 | + | return style | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func AccentStyle(t Theme) renderer.Style { | |
| 232 | + | if !usable(t) { | |
| 233 | + | return inverseStyle() | |
| 234 | + | } | |
| 235 | + | style := renderer.DefaultStyle() | |
| 236 | + | style.HasForegroundRGB = true | |
| 237 | + | style.ForegroundRGB = t.Foreground | |
| 238 | + | style.HasBackgroundRGB = true | |
| 239 | + | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | |
| 240 | + | return style | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func BorderStyle(t Theme) renderer.Style { | |
| 244 | + | if !usable(t) { | |
| 245 | + | return renderer.DefaultStyle() | |
| 246 | + | } | |
| 247 | + | style := renderer.DefaultStyle() | |
| 248 | + | style.HasForegroundRGB = true | |
| 249 | + | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 250 | + | return style | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func SelectionStyle(t Theme) renderer.Style { | |
| 254 | + | if !usable(t) { | |
| 255 | + | return inverseStyle() | |
| 256 | + | } | |
| 257 | + | return AccentStyle(t) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func usable(t Theme) bool { | |
| 261 | + | return t.Known && t.TrueColor && t.HasFG && t.HasBG | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func inverseStyle() renderer.Style { | |
| 265 | + | style := renderer.DefaultStyle() | |
| 266 | + | style.Inverse = true | |
| 267 | + | return style | |
| 268 | + | } | |
| @@ -0,0 +1,268 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const maxPending = 64 | |||
| 12 | + | ||||
| 13 | + | // Theme describes terminal default colors reported by OSC 10/11. | |||
| 14 | + | type Theme struct { | |||
| 15 | + | Foreground renderer.RGB | |||
| 16 | + | Background renderer.RGB | |||
| 17 | + | HasFG bool | |||
| 18 | + | HasBG bool | |||
| 19 | + | TrueColor bool | |||
| 20 | + | Known bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // ParseXColor parses the XParseColor formats commonly returned by OSC 10/11. | |||
| 24 | + | func ParseXColor(s string) (renderer.RGB, bool) { | |||
| 25 | + | if strings.HasPrefix(s, "#") { | |||
| 26 | + | if len(s) != 7 { | |||
| 27 | + | return renderer.RGB{}, false | |||
| 28 | + | } | |||
| 29 | + | r, ok := parseHexByte(s[1:3]) | |||
| 30 | + | if !ok { | |||
| 31 | + | return renderer.RGB{}, false | |||
| 32 | + | } | |||
| 33 | + | g, ok := parseHexByte(s[3:5]) | |||
| 34 | + | if !ok { | |||
| 35 | + | return renderer.RGB{}, false | |||
| 36 | + | } | |||
| 37 | + | b, ok := parseHexByte(s[5:7]) | |||
| 38 | + | if !ok { | |||
| 39 | + | return renderer.RGB{}, false | |||
| 40 | + | } | |||
| 41 | + | return renderer.RGB{R: r, G: g, B: b}, true | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | if !strings.HasPrefix(s, "rgb:") { | |||
| 45 | + | return renderer.RGB{}, false | |||
| 46 | + | } | |||
| 47 | + | parts := strings.Split(s[len("rgb:"):], "/") | |||
| 48 | + | if len(parts) != 3 { | |||
| 49 | + | return renderer.RGB{}, false | |||
| 50 | + | } | |||
| 51 | + | width := len(parts[0]) | |||
| 52 | + | if width != 2 && width != 4 { | |||
| 53 | + | return renderer.RGB{}, false | |||
| 54 | + | } | |||
| 55 | + | var out [3]uint8 | |||
| 56 | + | for i, part := range parts { | |||
| 57 | + | if len(part) != width { | |||
| 58 | + | return renderer.RGB{}, false | |||
| 59 | + | } | |||
| 60 | + | if width == 2 { | |||
| 61 | + | v, ok := parseHexByte(part) | |||
| 62 | + | if !ok { | |||
| 63 | + | return renderer.RGB{}, false | |||
| 64 | + | } | |||
| 65 | + | out[i] = v | |||
| 66 | + | continue | |||
| 67 | + | } | |||
| 68 | + | v, err := strconv.ParseUint(part, 16, 16) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return renderer.RGB{}, false | |||
| 71 | + | } | |||
| 72 | + | out[i] = uint8(v >> 8) | |||
| 73 | + | } | |||
| 74 | + | return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func parseHexByte(s string) (uint8, bool) { | |||
| 78 | + | v, err := strconv.ParseUint(s, 16, 8) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return 0, false | |||
| 81 | + | } | |||
| 82 | + | return uint8(v), true | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Scanner decodes OSC 10/11 color responses while preserving ordinary bytes. | |||
| 86 | + | type Scanner struct { | |||
| 87 | + | pending []byte | |||
| 88 | + | } | |||
| 89 | + | ||||
| 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, 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. | |||
| 98 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) { | |||
| 99 | + | if len(s.pending) > 0 { | |||
| 100 | + | combined := make([]byte, 0, len(s.pending)+len(data)) | |||
| 101 | + | combined = append(combined, s.pending...) | |||
| 102 | + | combined = append(combined, data...) | |||
| 103 | + | data = combined | |||
| 104 | + | s.pending = nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | byteStart := 0 | |||
| 108 | + | for i := 0; i < len(data); i++ { | |||
| 109 | + | if data[i] != '\x1b' { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |||
| 114 | + | if !completePrefix { | |||
| 115 | + | if possiblePrefix { | |||
| 116 | + | if byteStart < i { | |||
| 117 | + | onBytes(data[byteStart:i]) | |||
| 118 | + | } | |||
| 119 | + | s.bufferOrFlush(data[i:], onBytes) | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 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. | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | if byteStart < i { | |||
| 131 | + | onBytes(data[byteStart:i]) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | termStart, termEnd := findTerminator(data[i+5:]) | |||
| 135 | + | if termStart < 0 { | |||
| 136 | + | s.bufferOrFlush(data[i:], onBytes) | |||
| 137 | + | return | |||
| 138 | + | } | |||
| 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])) | |||
| 144 | + | if ok { | |||
| 145 | + | onColor(kind, rgb) | |||
| 146 | + | } else { | |||
| 147 | + | onBytes(raw) | |||
| 148 | + | } | |||
| 149 | + | i = termEnd - 1 | |||
| 150 | + | byteStart = termEnd | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | if byteStart < len(data) { | |||
| 154 | + | onBytes(data[byteStart:]) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) { | |||
| 159 | + | if len(partial) > maxPending { | |||
| 160 | + | onBytes(partial) | |||
| 161 | + | s.pending = nil | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | s.pending = append(s.pending, partial...) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||
| 168 | + | if len(data) < 2 { | |||
| 169 | + | return false, false | |||
| 170 | + | } | |||
| 171 | + | prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")} | |||
| 172 | + | for _, prefix := range prefixes { | |||
| 173 | + | if len(data) >= len(prefix) { | |||
| 174 | + | if bytes.Equal(data[:len(prefix)], prefix) { | |||
| 175 | + | return true, true | |||
| 176 | + | } | |||
| 177 | + | continue | |||
| 178 | + | } | |||
| 179 | + | if bytes.Equal(data, prefix[:len(data)]) { | |||
| 180 | + | return false, true | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | return false, false | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func findTerminator(data []byte) (start int, end int) { | |||
| 187 | + | for i := 0; i < len(data); i++ { | |||
| 188 | + | switch data[i] { | |||
| 189 | + | case '\x07': | |||
| 190 | + | return i, i + 1 | |||
| 191 | + | case '\x1b': | |||
| 192 | + | if i+1 < len(data) && data[i+1] == '\\' { | |||
| 193 | + | return i, i + 2 | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return -1, -1 | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Blend linearly interpolates between a and b. | |||
| 201 | + | func Blend(a, b renderer.RGB, t float64) renderer.RGB { | |||
| 202 | + | if t < 0 { | |||
| 203 | + | t = 0 | |||
| 204 | + | } | |||
| 205 | + | if t > 1 { | |||
| 206 | + | t = 1 | |||
| 207 | + | } | |||
| 208 | + | return renderer.RGB{ | |||
| 209 | + | R: blendByte(a.R, b.R, t), | |||
| 210 | + | G: blendByte(a.G, b.G, t), | |||
| 211 | + | B: blendByte(a.B, b.B, t), | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func blendByte(a, b uint8, t float64) uint8 { | |||
| 216 | + | return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func StatusBarStyle(t Theme) renderer.Style { | |||
| 220 | + | if !usable(t) { | |||
| 221 | + | return renderer.DefaultStyle() | |||
| 222 | + | } | |||
| 223 | + | style := renderer.DefaultStyle() | |||
| 224 | + | style.HasForegroundRGB = true | |||
| 225 | + | style.ForegroundRGB = t.Foreground | |||
| 226 | + | style.HasBackgroundRGB = true | |||
| 227 | + | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12) | |||
| 228 | + | return style | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func AccentStyle(t Theme) renderer.Style { | |||
| 232 | + | if !usable(t) { | |||
| 233 | + | return inverseStyle() | |||
| 234 | + | } | |||
| 235 | + | style := renderer.DefaultStyle() | |||
| 236 | + | style.HasForegroundRGB = true | |||
| 237 | + | style.ForegroundRGB = t.Foreground | |||
| 238 | + | style.HasBackgroundRGB = true | |||
| 239 | + | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | |||
| 240 | + | return style | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func BorderStyle(t Theme) renderer.Style { | |||
| 244 | + | if !usable(t) { | |||
| 245 | + | return renderer.DefaultStyle() | |||
| 246 | + | } | |||
| 247 | + | style := renderer.DefaultStyle() | |||
| 248 | + | style.HasForegroundRGB = true | |||
| 249 | + | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |||
| 250 | + | return style | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func SelectionStyle(t Theme) renderer.Style { | |||
| 254 | + | if !usable(t) { | |||
| 255 | + | return inverseStyle() | |||
| 256 | + | } | |||
| 257 | + | return AccentStyle(t) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func usable(t Theme) bool { | |||
| 261 | + | return t.Known && t.TrueColor && t.HasFG && t.HasBG | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func inverseStyle() renderer.Style { | |||
| 265 | + | style := renderer.DefaultStyle() | |||
| 266 | + | style.Inverse = true | |||
| 267 | + | return style | |||
| 268 | + | } | |||
A internal/usecase/theme/theme_test.go +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseXColor(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | in string | |
| 14 | + | want renderer.RGB | |
| 15 | + | ok bool | |
| 16 | + | }{ | |
| 17 | + | {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |
| 18 | + | {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |
| 19 | + | {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true}, | |
| 20 | + | {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false}, | |
| 21 | + | {name: "garbage rejected", in: "not-a-color", ok: false}, | |
| 22 | + | {name: "bad hex rejected", in: "#xyzxyz", ok: false}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | got, ok := ParseXColor(tt.in) | |
| 28 | + | if ok != tt.ok { | |
| 29 | + | t.Fatalf("ok=%v want %v", ok, tt.ok) | |
| 30 | + | } | |
| 31 | + | if ok && got != tt.want { | |
| 32 | + | t.Fatalf("rgb=%+v want %+v", got, tt.want) | |
| 33 | + | } | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestScannerExtractsBELAndSTColors(t *testing.T) { | |
| 39 | + | var s Scanner | |
| 40 | + | var colors []struct { | |
| 41 | + | kind int | |
| 42 | + | rgb renderer.RGB | |
| 43 | + | } | |
| 44 | + | var out bytes.Buffer | |
| 45 | + | ||
| 46 | + | s.Scan([]byte("a\x1b]10;#112233\x07b\x1b]11;rgb:4455/6677/8899\x1b\\c"), func(kind int, rgb renderer.RGB) { | |
| 47 | + | colors = append(colors, struct { | |
| 48 | + | kind int | |
| 49 | + | rgb renderer.RGB | |
| 50 | + | }{kind: kind, rgb: rgb}) | |
| 51 | + | }, func(b []byte) { out.Write(b) }) | |
| 52 | + | ||
| 53 | + | if out.String() != "abc" { | |
| 54 | + | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | |
| 55 | + | } | |
| 56 | + | if len(colors) != 2 { | |
| 57 | + | t.Fatalf("colors len=%d want 2", len(colors)) | |
| 58 | + | } | |
| 59 | + | if colors[0].kind != 10 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) { | |
| 60 | + | t.Fatalf("first color=%+v", colors[0]) | |
| 61 | + | } | |
| 62 | + | if colors[1].kind != 11 || colors[1].rgb != (renderer.RGB{R: 0x44, G: 0x66, B: 0x88}) { | |
| 63 | + | t.Fatalf("second color=%+v", colors[1]) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestScannerHandlesSplitSequences(t *testing.T) { | |
| 68 | + | var s Scanner | |
| 69 | + | var got []renderer.RGB | |
| 70 | + | var out bytes.Buffer | |
| 71 | + | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | |
| 72 | + | onBytes := func(b []byte) { out.Write(b) } | |
| 73 | + | ||
| 74 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes) | |
| 75 | + | s.Scan([]byte("3456\x07y"), onColor, onBytes) | |
| 76 | + | ||
| 77 | + | if out.String() != "xy" { | |
| 78 | + | t.Fatalf("passthrough=%q want xy", out.String()) | |
| 79 | + | } | |
| 80 | + | if len(got) != 1 || got[0] != (renderer.RGB{R: 0x12, G: 0x34, B: 0x56}) { | |
| 81 | + | t.Fatalf("colors=%+v", got) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |
| 86 | + | var s Scanner | |
| 87 | + | var out bytes.Buffer | |
| 88 | + | colors := 0 | |
| 89 | + | ||
| 90 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |
| 91 | + | ||
| 92 | + | if colors != 0 { | |
| 93 | + | t.Fatalf("colors=%d want 0", colors) | |
| 94 | + | } | |
| 95 | + | want := "ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef" | |
| 96 | + | if out.String() != want { | |
| 97 | + | t.Fatalf("passthrough=%q want %q", out.String(), want) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |
| 102 | + | var s Scanner | |
| 103 | + | var out bytes.Buffer | |
| 104 | + | colors := 0 | |
| 105 | + | ||
| 106 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |
| 107 | + | ||
| 108 | + | if colors != 0 { | |
| 109 | + | t.Fatalf("colors=%d want 0", colors) | |
| 110 | + | } | |
| 111 | + | if out.String() != "\x1b" { | |
| 112 | + | t.Fatalf("passthrough=%q want ESC", out.String()) | |
| 113 | + | } | |
| 114 | + | } | |
| 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 | + | ||
| 188 | + | func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |
| 189 | + | var s Scanner | |
| 190 | + | var out bytes.Buffer | |
| 191 | + | ||
| 192 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |
| 193 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |
| 194 | + | ||
| 195 | + | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | |
| 196 | + | t.Fatalf("overflow passthrough=%q", out.String()) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |
| 201 | + | unknown := Theme{} | |
| 202 | + | if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) { | |
| 203 | + | t.Fatalf("status fallback=%+v", got) | |
| 204 | + | } | |
| 205 | + | if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) { | |
| 206 | + | t.Fatalf("border fallback=%+v", got) | |
| 207 | + | } | |
| 208 | + | inverse := renderer.DefaultStyle() | |
| 209 | + | inverse.Inverse = true | |
| 210 | + | if got := AccentStyle(unknown); !got.Equal(inverse) { | |
| 211 | + | t.Fatalf("accent fallback=%+v want inverse", got) | |
| 212 | + | } | |
| 213 | + | if got := SelectionStyle(unknown); !got.Equal(inverse) { | |
| 214 | + | t.Fatalf("selection fallback=%+v want inverse", got) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 218 | + | if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want { | |
| 219 | + | t.Fatalf("blend=%+v want %+v", got, want) | |
| 220 | + | } | |
| 221 | + | status := StatusBarStyle(theme) | |
| 222 | + | if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground { | |
| 223 | + | t.Fatalf("status themed=%+v", status) | |
| 224 | + | } | |
| 225 | + | accent := AccentStyle(theme) | |
| 226 | + | if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) { | |
| 227 | + | t.Fatalf("accent themed=%+v", accent) | |
| 228 | + | } | |
| 229 | + | border := BorderStyle(theme) | |
| 230 | + | if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) { | |
| 231 | + | t.Fatalf("border themed=%+v", border) | |
| 232 | + | } | |
| 233 | + | } | |
| @@ -0,0 +1,233 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseXColor(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | in string | |||
| 14 | + | want renderer.RGB | |||
| 15 | + | ok bool | |||
| 16 | + | }{ | |||
| 17 | + | {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |||
| 18 | + | {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |||
| 19 | + | {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true}, | |||
| 20 | + | {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false}, | |||
| 21 | + | {name: "garbage rejected", in: "not-a-color", ok: false}, | |||
| 22 | + | {name: "bad hex rejected", in: "#xyzxyz", ok: false}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | got, ok := ParseXColor(tt.in) | |||
| 28 | + | if ok != tt.ok { | |||
| 29 | + | t.Fatalf("ok=%v want %v", ok, tt.ok) | |||
| 30 | + | } | |||
| 31 | + | if ok && got != tt.want { | |||
| 32 | + | t.Fatalf("rgb=%+v want %+v", got, tt.want) | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestScannerExtractsBELAndSTColors(t *testing.T) { | |||
| 39 | + | var s Scanner | |||
| 40 | + | var colors []struct { | |||
| 41 | + | kind int | |||
| 42 | + | rgb renderer.RGB | |||
| 43 | + | } | |||
| 44 | + | var out bytes.Buffer | |||
| 45 | + | ||||
| 46 | + | s.Scan([]byte("a\x1b]10;#112233\x07b\x1b]11;rgb:4455/6677/8899\x1b\\c"), func(kind int, rgb renderer.RGB) { | |||
| 47 | + | colors = append(colors, struct { | |||
| 48 | + | kind int | |||
| 49 | + | rgb renderer.RGB | |||
| 50 | + | }{kind: kind, rgb: rgb}) | |||
| 51 | + | }, func(b []byte) { out.Write(b) }) | |||
| 52 | + | ||||
| 53 | + | if out.String() != "abc" { | |||
| 54 | + | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | |||
| 55 | + | } | |||
| 56 | + | if len(colors) != 2 { | |||
| 57 | + | t.Fatalf("colors len=%d want 2", len(colors)) | |||
| 58 | + | } | |||
| 59 | + | if colors[0].kind != 10 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) { | |||
| 60 | + | t.Fatalf("first color=%+v", colors[0]) | |||
| 61 | + | } | |||
| 62 | + | if colors[1].kind != 11 || colors[1].rgb != (renderer.RGB{R: 0x44, G: 0x66, B: 0x88}) { | |||
| 63 | + | t.Fatalf("second color=%+v", colors[1]) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestScannerHandlesSplitSequences(t *testing.T) { | |||
| 68 | + | var s Scanner | |||
| 69 | + | var got []renderer.RGB | |||
| 70 | + | var out bytes.Buffer | |||
| 71 | + | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | |||
| 72 | + | onBytes := func(b []byte) { out.Write(b) } | |||
| 73 | + | ||||
| 74 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes) | |||
| 75 | + | s.Scan([]byte("3456\x07y"), onColor, onBytes) | |||
| 76 | + | ||||
| 77 | + | if out.String() != "xy" { | |||
| 78 | + | t.Fatalf("passthrough=%q want xy", out.String()) | |||
| 79 | + | } | |||
| 80 | + | if len(got) != 1 || got[0] != (renderer.RGB{R: 0x12, G: 0x34, B: 0x56}) { | |||
| 81 | + | t.Fatalf("colors=%+v", got) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |||
| 86 | + | var s Scanner | |||
| 87 | + | var out bytes.Buffer | |||
| 88 | + | colors := 0 | |||
| 89 | + | ||||
| 90 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |||
| 91 | + | ||||
| 92 | + | if colors != 0 { | |||
| 93 | + | t.Fatalf("colors=%d want 0", colors) | |||
| 94 | + | } | |||
| 95 | + | want := "ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef" | |||
| 96 | + | if out.String() != want { | |||
| 97 | + | t.Fatalf("passthrough=%q want %q", out.String(), want) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||
| 102 | + | var s Scanner | |||
| 103 | + | var out bytes.Buffer | |||
| 104 | + | colors := 0 | |||
| 105 | + | ||||
| 106 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) }) | |||
| 107 | + | ||||
| 108 | + | if colors != 0 { | |||
| 109 | + | t.Fatalf("colors=%d want 0", colors) | |||
| 110 | + | } | |||
| 111 | + | if out.String() != "\x1b" { | |||
| 112 | + | t.Fatalf("passthrough=%q want ESC", out.String()) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 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 | + | ||||
| 188 | + | func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |||
| 189 | + | var s Scanner | |||
| 190 | + | var out bytes.Buffer | |||
| 191 | + | ||||
| 192 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |||
| 193 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) }) | |||
| 194 | + | ||||
| 195 | + | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | |||
| 196 | + | t.Fatalf("overflow passthrough=%q", out.String()) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||
| 201 | + | unknown := Theme{} | |||
| 202 | + | if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) { | |||
| 203 | + | t.Fatalf("status fallback=%+v", got) | |||
| 204 | + | } | |||
| 205 | + | if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) { | |||
| 206 | + | t.Fatalf("border fallback=%+v", got) | |||
| 207 | + | } | |||
| 208 | + | inverse := renderer.DefaultStyle() | |||
| 209 | + | inverse.Inverse = true | |||
| 210 | + | if got := AccentStyle(unknown); !got.Equal(inverse) { | |||
| 211 | + | t.Fatalf("accent fallback=%+v want inverse", got) | |||
| 212 | + | } | |||
| 213 | + | if got := SelectionStyle(unknown); !got.Equal(inverse) { | |||
| 214 | + | t.Fatalf("selection fallback=%+v want inverse", got) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 218 | + | if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want { | |||
| 219 | + | t.Fatalf("blend=%+v want %+v", got, want) | |||
| 220 | + | } | |||
| 221 | + | status := StatusBarStyle(theme) | |||
| 222 | + | if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground { | |||
| 223 | + | t.Fatalf("status themed=%+v", status) | |||
| 224 | + | } | |||
| 225 | + | accent := AccentStyle(theme) | |||
| 226 | + | if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) { | |||
| 227 | + | t.Fatalf("accent themed=%+v", accent) | |||
| 228 | + | } | |||
| 229 | + | border := BorderStyle(theme) | |||
| 230 | + | if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) { | |||
| 231 | + | t.Fatalf("border themed=%+v", border) | |||
| 232 | + | } | |||
| 233 | + | } | |||
M internal/usecase/ui/textinput.go +4 -1
| @@ -21,7 +21,7 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||
| 21 | 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | |
| 22 | 22 | ||
| 23 | 23 | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | |
| 24 | - | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | |
| 24 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) { | |
| 25 | 25 | if y < 0 || y >= f.Height { | |
| 26 | 26 | return | |
| 27 | 27 | } | |
| @@ -29,6 +29,9 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer | |||
| 29 | 29 | if x >= 0 && x < f.Width { | |
| 30 | 30 | caret := style | |
| 31 | 31 | caret.Inverse = true | |
| 32 | + | if len(caretStyle) > 0 { | |
| 33 | + | caret = caretStyle[0] | |
| 34 | + | } | |
| 32 | 35 | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | |
| 33 | 36 | } | |
| 34 | 37 | } | |
| @@ -21,7 +21,7 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||||
| 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | ||
| 22 | 22 | ||||
| 23 | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | 23 | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | ||
| 24 | - | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | 24 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) { |
| 25 | if y < 0 || y >= f.Height { | 25 | if y < 0 || y >= f.Height { | ||
| 26 | return | 26 | return | ||
| 27 | } | 27 | } | ||
| @@ -29,6 +29,9 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer | |||||
| 29 | if x >= 0 && x < f.Width { | 29 | if x >= 0 && x < f.Width { | ||
| 30 | caret := style | 30 | caret := style | ||
| 31 | caret.Inverse = true | 31 | caret.Inverse = true | ||
| 32 | + | if len(caretStyle) > 0 { | |||
| 33 | + | caret = caretStyle[0] | |||
| 34 | + | } | |||
| 32 | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | 35 | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | ||
| 33 | } | 36 | } | ||
| 34 | } | 37 | } | ||
M pkg/vt/query_test.go +56 -0
| @@ -3,6 +3,8 @@ package vt | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestScreenQueryResponses(t *testing.T) { | |
| @@ -44,9 +46,63 @@ func TestScreenQueryResponses(t *testing.T) { | |||
| 44 | 46 | } | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 49 | + | func TestScreenOSCDefaultColorQueries(t *testing.T) { | |
| 50 | + | cases := []struct { | |
| 51 | + | name string | |
| 52 | + | input string | |
| 53 | + | want string | |
| 54 | + | }{ | |
| 55 | + | {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"}, | |
| 56 | + | {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"}, | |
| 57 | + | {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"}, | |
| 58 | + | {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"}, | |
| 59 | + | } | |
| 60 | + | for _, tc := range cases { | |
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |
| 62 | + | s := NewScreen(80, 24) | |
| 63 | + | s.SetDefaultColors( | |
| 64 | + | renderer.RGB{R: 0x12, G: 0x34, B: 0x56}, | |
| 65 | + | renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc}, | |
| 66 | + | true, | |
| 67 | + | ) | |
| 68 | + | var got bytes.Buffer | |
| 69 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 70 | + | s.Write([]byte(tc.input)) | |
| 71 | + | if got.String() != tc.want { | |
| 72 | + | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) { | |
| 79 | + | s := NewScreen(80, 24) | |
| 80 | + | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false) | |
| 81 | + | var got bytes.Buffer | |
| 82 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 83 | + | s.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 84 | + | if got.Len() != 0 { | |
| 85 | + | t.Fatalf("response = %q, want silence", got.String()) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) { | |
| 90 | + | s := NewScreen(80, 24) | |
| 91 | + | s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true) | |
| 92 | + | var got bytes.Buffer | |
| 93 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 94 | + | s.Write([]byte("\x1b]10")) | |
| 95 | + | s.Write([]byte(";?\a")) | |
| 96 | + | if got.String() != "\x1b]10;rgb:0101/0202/0303\a" { | |
| 97 | + | t.Fatalf("response = %q", got.String()) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 47 | 101 | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | |
| 48 | 102 | s := NewScreen(80, 24) | |
| 49 | 103 | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | |
| 104 | + | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true) | |
| 105 | + | s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\")) | |
| 50 | 106 | } | |
| 51 | 107 | ||
| 52 | 108 | func TestCSIuDispatch(t *testing.T) { | |
| @@ -3,6 +3,8 @@ package vt | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func TestScreenQueryResponses(t *testing.T) { | 10 | func TestScreenQueryResponses(t *testing.T) { | ||
| @@ -44,9 +46,63 @@ func TestScreenQueryResponses(t *testing.T) { | |||||
| 44 | } | 46 | } | ||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 49 | + | func TestScreenOSCDefaultColorQueries(t *testing.T) { | |||
| 50 | + | cases := []struct { | |||
| 51 | + | name string | |||
| 52 | + | input string | |||
| 53 | + | want string | |||
| 54 | + | }{ | |||
| 55 | + | {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"}, | |||
| 56 | + | {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"}, | |||
| 57 | + | {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"}, | |||
| 58 | + | {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"}, | |||
| 59 | + | } | |||
| 60 | + | for _, tc := range cases { | |||
| 61 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 62 | + | s := NewScreen(80, 24) | |||
| 63 | + | s.SetDefaultColors( | |||
| 64 | + | renderer.RGB{R: 0x12, G: 0x34, B: 0x56}, | |||
| 65 | + | renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc}, | |||
| 66 | + | true, | |||
| 67 | + | ) | |||
| 68 | + | var got bytes.Buffer | |||
| 69 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 70 | + | s.Write([]byte(tc.input)) | |||
| 71 | + | if got.String() != tc.want { | |||
| 72 | + | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) { | |||
| 79 | + | s := NewScreen(80, 24) | |||
| 80 | + | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false) | |||
| 81 | + | var got bytes.Buffer | |||
| 82 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 83 | + | s.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 84 | + | if got.Len() != 0 { | |||
| 85 | + | t.Fatalf("response = %q, want silence", got.String()) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) { | |||
| 90 | + | s := NewScreen(80, 24) | |||
| 91 | + | s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true) | |||
| 92 | + | var got bytes.Buffer | |||
| 93 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 94 | + | s.Write([]byte("\x1b]10")) | |||
| 95 | + | s.Write([]byte(";?\a")) | |||
| 96 | + | if got.String() != "\x1b]10;rgb:0101/0202/0303\a" { | |||
| 97 | + | t.Fatalf("response = %q", got.String()) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 47 | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | 101 | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | ||
| 48 | s := NewScreen(80, 24) | 102 | s := NewScreen(80, 24) | ||
| 49 | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | 103 | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | ||
| 104 | + | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true) | |||
| 105 | + | s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\")) | |||
| 50 | } | 106 | } | ||
| 51 | 107 | ||||
| 52 | func TestCSIuDispatch(t *testing.T) { | 108 | func TestCSIuDispatch(t *testing.T) { | ||
M pkg/vt/screen.go +51 -2
| @@ -40,6 +40,10 @@ type Screen struct { | |||
| 40 | 40 | // The host wires it to the PTY input. Nil disables responses. | |
| 41 | 41 | OnResponse func([]byte) | |
| 42 | 42 | ||
| 43 | + | defaultFG renderer.RGB | |
| 44 | + | defaultBG renderer.RGB | |
| 45 | + | defaultColorsKnown bool | |
| 46 | + | ||
| 43 | 47 | damage []renderer.Damage | |
| 44 | 48 | escapeBuf []byte | |
| 45 | 49 | ||
| @@ -121,6 +125,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 121 | 125 | // and never sends the matching end sequence. | |
| 122 | 126 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |
| 123 | 127 | ||
| 128 | + | // SetDefaultColors sets the terminal default foreground/background colors used | |
| 129 | + | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |
| 130 | + | // silent until known colors are supplied again. | |
| 131 | + | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |
| 132 | + | s.defaultFG = fg | |
| 133 | + | s.defaultBG = bg | |
| 134 | + | s.defaultColorsKnown = ok | |
| 135 | + | } | |
| 136 | + | ||
| 124 | 137 | func (s *Screen) CursorRow() int { return s.Row } | |
| 125 | 138 | func (s *Screen) CursorCol() int { return s.Col } | |
| 126 | 139 | func (s *Screen) CursorVisible() bool { | |
| @@ -480,7 +493,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||
| 480 | 493 | } | |
| 481 | 494 | switch data[1] { | |
| 482 | 495 | case ']': | |
| 483 | - | return consumeOSC(data) | |
| 496 | + | return s.consumeOSC(data) | |
| 484 | 497 | case 'P': | |
| 485 | 498 | return consumeSTString(data) | |
| 486 | 499 | case '_', '^', 'X': | |
| @@ -541,13 +554,15 @@ func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) { | |||
| 541 | 554 | return end + 1, false | |
| 542 | 555 | } | |
| 543 | 556 | ||
| 544 | - | func consumeOSC(data []byte) (consumed int, partial bool) { | |
| 557 | + | func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) { | |
| 545 | 558 | for i := 2; i < len(data); i++ { | |
| 546 | 559 | switch data[i] { | |
| 547 | 560 | case 0x07: | |
| 561 | + | s.applyOSC(data[2:i], []byte{0x07}) | |
| 548 | 562 | return i + 1, false | |
| 549 | 563 | case 0x1b: | |
| 550 | 564 | if i+1 < len(data) && data[i+1] == '\\' { | |
| 565 | + | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | |
| 551 | 566 | return i + 2, false | |
| 552 | 567 | } | |
| 553 | 568 | } | |
| @@ -555,6 +570,40 @@ func consumeOSC(data []byte) (consumed int, partial bool) { | |||
| 555 | 570 | return 0, true | |
| 556 | 571 | } | |
| 557 | 572 | ||
| 573 | + | func (s *Screen) applyOSC(payload, terminator []byte) { | |
| 574 | + | if !s.defaultColorsKnown { | |
| 575 | + | return | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | var color renderer.RGB | |
| 579 | + | switch string(payload) { | |
| 580 | + | case "10;?": | |
| 581 | + | color = s.defaultFG | |
| 582 | + | case "11;?": | |
| 583 | + | color = s.defaultBG | |
| 584 | + | default: | |
| 585 | + | return | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator)) | |
| 589 | + | resp = append(resp, "\x1b]"...) | |
| 590 | + | resp = append(resp, payload[:2]...) | |
| 591 | + | resp = append(resp, ";rgb:"...) | |
| 592 | + | resp = appendOSCColorComponent(resp, color.R) | |
| 593 | + | resp = append(resp, '/') | |
| 594 | + | resp = appendOSCColorComponent(resp, color.G) | |
| 595 | + | resp = append(resp, '/') | |
| 596 | + | resp = appendOSCColorComponent(resp, color.B) | |
| 597 | + | resp = append(resp, terminator...) | |
| 598 | + | s.respond(resp) | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func appendOSCColorComponent(dst []byte, c uint8) []byte { | |
| 602 | + | const hex = "0123456789abcdef" | |
| 603 | + | v := uint16(c)<<8 | uint16(c) | |
| 604 | + | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | |
| 605 | + | } | |
| 606 | + | ||
| 558 | 607 | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 559 | 608 | for i := 2; i < len(data); i++ { | |
| 560 | 609 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| @@ -40,6 +40,10 @@ type Screen struct { | |||||
| 40 | // The host wires it to the PTY input. Nil disables responses. | 40 | // The host wires it to the PTY input. Nil disables responses. | ||
| 41 | OnResponse func([]byte) | 41 | OnResponse func([]byte) | ||
| 42 | 42 | ||||
| 43 | + | defaultFG renderer.RGB | |||
| 44 | + | defaultBG renderer.RGB | |||
| 45 | + | defaultColorsKnown bool | |||
| 46 | + | ||||
| 43 | damage []renderer.Damage | 47 | damage []renderer.Damage | ||
| 44 | escapeBuf []byte | 48 | escapeBuf []byte | ||
| 45 | 49 | ||||
| @@ -121,6 +125,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||||
| 121 | // and never sends the matching end sequence. | 125 | // and never sends the matching end sequence. | ||
| 122 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | 126 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | ||
| 123 | 127 | ||||
| 128 | + | // SetDefaultColors sets the terminal default foreground/background colors used | |||
| 129 | + | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |||
| 130 | + | // silent until known colors are supplied again. | |||
| 131 | + | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |||
| 132 | + | s.defaultFG = fg | |||
| 133 | + | s.defaultBG = bg | |||
| 134 | + | s.defaultColorsKnown = ok | |||
| 135 | + | } | |||
| 136 | + | ||||
| 124 | func (s *Screen) CursorRow() int { return s.Row } | 137 | func (s *Screen) CursorRow() int { return s.Row } | ||
| 125 | func (s *Screen) CursorCol() int { return s.Col } | 138 | func (s *Screen) CursorCol() int { return s.Col } | ||
| 126 | func (s *Screen) CursorVisible() bool { | 139 | func (s *Screen) CursorVisible() bool { | ||
| @@ -480,7 +493,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||||
| 480 | } | 493 | } | ||
| 481 | switch data[1] { | 494 | switch data[1] { | ||
| 482 | case ']': | 495 | case ']': | ||
| 483 | - | return consumeOSC(data) | 496 | + | return s.consumeOSC(data) |
| 484 | case 'P': | 497 | case 'P': | ||
| 485 | return consumeSTString(data) | 498 | return consumeSTString(data) | ||
| 486 | case '_', '^', 'X': | 499 | case '_', '^', 'X': | ||
| @@ -541,13 +554,15 @@ func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) { | |||||
| 541 | return end + 1, false | 554 | return end + 1, false | ||
| 542 | } | 555 | } | ||
| 543 | 556 | ||||
| 544 | - | func consumeOSC(data []byte) (consumed int, partial bool) { | 557 | + | func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) { |
| 545 | for i := 2; i < len(data); i++ { | 558 | for i := 2; i < len(data); i++ { | ||
| 546 | switch data[i] { | 559 | switch data[i] { | ||
| 547 | case 0x07: | 560 | case 0x07: | ||
| 561 | + | s.applyOSC(data[2:i], []byte{0x07}) | |||
| 548 | return i + 1, false | 562 | return i + 1, false | ||
| 549 | case 0x1b: | 563 | case 0x1b: | ||
| 550 | if i+1 < len(data) && data[i+1] == '\\' { | 564 | if i+1 < len(data) && data[i+1] == '\\' { | ||
| 565 | + | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | |||
| 551 | return i + 2, false | 566 | return i + 2, false | ||
| 552 | } | 567 | } | ||
| 553 | } | 568 | } | ||
| @@ -555,6 +570,40 @@ func consumeOSC(data []byte) (consumed int, partial bool) { | |||||
| 555 | return 0, true | 570 | return 0, true | ||
| 556 | } | 571 | } | ||
| 557 | 572 | ||||
| 573 | + | func (s *Screen) applyOSC(payload, terminator []byte) { | |||
| 574 | + | if !s.defaultColorsKnown { | |||
| 575 | + | return | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | var color renderer.RGB | |||
| 579 | + | switch string(payload) { | |||
| 580 | + | case "10;?": | |||
| 581 | + | color = s.defaultFG | |||
| 582 | + | case "11;?": | |||
| 583 | + | color = s.defaultBG | |||
| 584 | + | default: | |||
| 585 | + | return | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator)) | |||
| 589 | + | resp = append(resp, "\x1b]"...) | |||
| 590 | + | resp = append(resp, payload[:2]...) | |||
| 591 | + | resp = append(resp, ";rgb:"...) | |||
| 592 | + | resp = appendOSCColorComponent(resp, color.R) | |||
| 593 | + | resp = append(resp, '/') | |||
| 594 | + | resp = appendOSCColorComponent(resp, color.G) | |||
| 595 | + | resp = append(resp, '/') | |||
| 596 | + | resp = appendOSCColorComponent(resp, color.B) | |||
| 597 | + | resp = append(resp, terminator...) | |||
| 598 | + | s.respond(resp) | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 602 | + | const hex = "0123456789abcdef" | |||
| 603 | + | v := uint16(c)<<8 | uint16(c) | |||
| 604 | + | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | |||
| 605 | + | } | |||
| 606 | + | ||||
| 558 | func consumeSTString(data []byte) (consumed int, partial bool) { | 607 | func consumeSTString(data []byte) (consumed int, partial bool) { | ||
| 559 | for i := 2; i < len(data); i++ { | 608 | for i := 2; i < len(data); i++ { | ||
| 560 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | 609 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | ||