refactor(terminal): model color capability as ColorCapabilities with one profile mapping
Introduce terminalcap.ColorCapabilities (Mode, Source) with RGB() and Colors(), rename Indexed256 to ANSI256, and move the Detect-then-declared-truecolor precedence into terminalcap.Resolve. Replace DetectTrueColor and the plain TrueColor bool plumbing in the client with ColorCapabilities, and share one usecase/colorprofile.Profile mapping between the daemon output renderer and the client pickers. Wire fields (Hello.TrueColor, Theme.TrueColor) are unchanged and filled from RGB().
- commit
- 0103d519c4f1c6474c3663bcc7aa6f5fdcfd1511
- author
- brice · · 2026-10-03 12:21 UTC
- committer
- brice · · 2026-10-03 14:14 UTC
- parent
- 8a884f0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
29 files · +243 -103
M boundary_test.go +3 -0
| @@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||
| 116 | 116 | {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false}, | |
| 117 | 117 | {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false}, | |
| 118 | 118 | {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false}, | |
| 119 | + | // Shared color-profile policy is a leaf use case both sides may import. | |
| 120 | + | {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true}, | |
| 121 | + | {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true}, | |
| 119 | 122 | } | |
| 120 | 123 | for _, tt := range tests { | |
| 121 | 124 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||||
| 116 | {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false}, | 116 | {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false}, | ||
| 117 | {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false}, | 117 | {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false}, | ||
| 118 | {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false}, | 118 | {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false}, | ||
| 119 | + | // Shared color-profile policy is a leaf use case both sides may import. | |||
| 120 | + | {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true}, | |||
| 121 | + | {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true}, | |||
| 119 | } | 122 | } | ||
| 120 | for _, tt := range tests { | 123 | for _, tt := range tests { | ||
| 121 | t.Run(tt.name, func(t *testing.T) { | 124 | t.Run(tt.name, func(t *testing.T) { | ||
M docs/architecture.md +1 -0
| @@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation. | |||
| 24 | 24 | carrying `Envelope{Payload}` (one complete serialized directional | |
| 25 | 25 | envelope, no type byte). | |
| 26 | 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | |
| 27 | + | - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer. | |
| 27 | 28 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | |
| 28 | 29 | - `internal/adapters/sessionwire`: translates between typed session | |
| 29 | 30 | connections and raw wire transports: directional `oneof` envelope | |
| @@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation. | |||||
| 24 | carrying `Envelope{Payload}` (one complete serialized directional | 24 | carrying `Envelope{Payload}` (one complete serialized directional | ||
| 25 | envelope, no type byte). | 25 | envelope, no type byte). | ||
| 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | ||
| 27 | + | - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer. | |||
| 27 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | 28 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | ||
| 28 | - `internal/adapters/sessionwire`: translates between typed session | 29 | - `internal/adapters/sessionwire`: translates between typed session | ||
| 29 | connections and raw wire transports: directional `oneof` envelope | 30 | connections and raw wire transports: directional `oneof` envelope | ||
M internal/app/broker_client.go +14 -5
| @@ -283,7 +283,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 283 | 283 | if reader == nil { | |
| 284 | 284 | reader = clipboard.New() | |
| 285 | 285 | } | |
| 286 | - | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | |
| 286 | + | picker := client.NewPicker(clk, 0, attachmentEnv.Color) | |
| 287 | 287 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | |
| 288 | 288 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 289 | 289 | Logger: cfg.Logger, | |
| @@ -612,13 +612,22 @@ var ( | |||
| 612 | 612 | // process environment themselves. | |
| 613 | 613 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | |
| 614 | 614 | return client.AttachmentEnvironment{ | |
| 615 | - | TermEnv: os.Getenv("TERM"), | |
| 616 | - | Cwd: currentWorkingDirectory(), | |
| 617 | - | TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 618 | - | PID: uint32(os.Getpid()), | |
| 615 | + | TermEnv: os.Getenv("TERM"), | |
| 616 | + | Cwd: currentWorkingDirectory(), | |
| 617 | + | Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 618 | + | PID: uint32(os.Getpid()), | |
| 619 | 619 | } | |
| 620 | 620 | } | |
| 621 | 621 | ||
| 622 | + | // detectTerminalColor derives the color capability from the process | |
| 623 | + | // environment. Explicit TERM/COLORTERM values override env entries. | |
| 624 | + | func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities { | |
| 625 | + | detectionEnv := make([]string, 0, len(env)+2) | |
| 626 | + | detectionEnv = append(detectionEnv, env...) | |
| 627 | + | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | |
| 628 | + | return terminalcap.Detect(detectionEnv).Color | |
| 629 | + | } | |
| 630 | + | ||
| 622 | 631 | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | |
| 623 | 632 | // a client that owns a real outer terminal: it probes that terminal once and | |
| 624 | 633 | // applies the user's keyboard setting. Virtual terminals never probe. | |
| @@ -283,7 +283,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 283 | if reader == nil { | 283 | if reader == nil { | ||
| 284 | reader = clipboard.New() | 284 | reader = clipboard.New() | ||
| 285 | } | 285 | } | ||
| 286 | - | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | 286 | + | picker := client.NewPicker(clk, 0, attachmentEnv.Color) |
| 287 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | 287 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | ||
| 288 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | 288 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | ||
| 289 | Logger: cfg.Logger, | 289 | Logger: cfg.Logger, | ||
| @@ -612,13 +612,22 @@ var ( | |||||
| 612 | // process environment themselves. | 612 | // process environment themselves. | ||
| 613 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | 613 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | ||
| 614 | return client.AttachmentEnvironment{ | 614 | return client.AttachmentEnvironment{ | ||
| 615 | - | TermEnv: os.Getenv("TERM"), | 615 | + | TermEnv: os.Getenv("TERM"), |
| 616 | - | Cwd: currentWorkingDirectory(), | 616 | + | Cwd: currentWorkingDirectory(), |
| 617 | - | TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | 617 | + | Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), |
| 618 | - | PID: uint32(os.Getpid()), | 618 | + | PID: uint32(os.Getpid()), |
| 619 | } | 619 | } | ||
| 620 | } | 620 | } | ||
| 621 | 621 | ||||
| 622 | + | // detectTerminalColor derives the color capability from the process | |||
| 623 | + | // environment. Explicit TERM/COLORTERM values override env entries. | |||
| 624 | + | func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities { | |||
| 625 | + | detectionEnv := make([]string, 0, len(env)+2) | |||
| 626 | + | detectionEnv = append(detectionEnv, env...) | |||
| 627 | + | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | |||
| 628 | + | return terminalcap.Detect(detectionEnv).Color | |||
| 629 | + | } | |||
| 630 | + | ||||
| 622 | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | 631 | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | ||
| 623 | // a client that owns a real outer terminal: it probes that terminal once and | 632 | // a client that owns a real outer terminal: it probes that terminal once and | ||
| 624 | // applies the user's keyboard setting. Virtual terminals never probe. | 633 | // applies the user's keyboard setting. Virtual terminals never probe. | ||
M internal/app/broker_client_test.go +21 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 19 | 19 | "github.com/bnema/vev/internal/adapters/clock" | |
| 20 | 20 | "github.com/bnema/vev/internal/domain" | |
| 21 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 21 | 22 | "github.com/bnema/vev/internal/ports" | |
| 22 | 23 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 23 | 24 | "github.com/bnema/vev/internal/protocol" | |
| @@ -1162,3 +1163,23 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po | |||
| 1162 | 1163 | } | |
| 1163 | 1164 | } | |
| 1164 | 1165 | } | |
| 1166 | + | ||
| 1167 | + | func TestDetectTerminalColor(t *testing.T) { | |
| 1168 | + | tests := []struct { | |
| 1169 | + | name string | |
| 1170 | + | term string | |
| 1171 | + | colorTerm string | |
| 1172 | + | env []string | |
| 1173 | + | want terminalcap.ColorCapabilities | |
| 1174 | + | }{ | |
| 1175 | + | {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}}, | |
| 1176 | + | {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |
| 1177 | + | {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, | |
| 1178 | + | {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |
| 1179 | + | } | |
| 1180 | + | for _, tt := range tests { | |
| 1181 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1182 | + | require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env)) | |
| 1183 | + | }) | |
| 1184 | + | } | |
| 1185 | + | } | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 19 | "github.com/bnema/vev/internal/adapters/clock" | 19 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 20 | "github.com/bnema/vev/internal/domain" | 20 | "github.com/bnema/vev/internal/domain" | ||
| 21 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 21 | "github.com/bnema/vev/internal/ports" | 22 | "github.com/bnema/vev/internal/ports" | ||
| 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 23 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 23 | "github.com/bnema/vev/internal/protocol" | 24 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -1162,3 +1163,23 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po | |||||
| 1162 | } | 1163 | } | ||
| 1163 | } | 1164 | } | ||
| 1164 | } | 1165 | } | ||
| 1166 | + | ||||
| 1167 | + | func TestDetectTerminalColor(t *testing.T) { | |||
| 1168 | + | tests := []struct { | |||
| 1169 | + | name string | |||
| 1170 | + | term string | |||
| 1171 | + | colorTerm string | |||
| 1172 | + | env []string | |||
| 1173 | + | want terminalcap.ColorCapabilities | |||
| 1174 | + | }{ | |||
| 1175 | + | {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}}, | |||
| 1176 | + | {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |||
| 1177 | + | {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, | |||
| 1178 | + | {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |||
| 1179 | + | } | |||
| 1180 | + | for _, tt := range tests { | |||
| 1181 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1182 | + | require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env)) | |||
| 1183 | + | }) | |||
| 1184 | + | } | |||
| 1185 | + | } | |||
M internal/app/broker_offline_client_render_test.go +2 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| @@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) { | |||
| 116 | 117 | // daemon with a single session, so Render produces a real frame at any size. | |
| 117 | 118 | func offlineRenderPicker(t *testing.T) *client.Picker { | |
| 118 | 119 | t.Helper() | |
| 119 | - | picker := client.NewPicker(nil, 0, true) | |
| 120 | + | picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) | |
| 120 | 121 | picker.ApplySnapshot(ports.BrokerSnapshot{ | |
| 121 | 122 | Epoch: 1, | |
| 122 | 123 | Revision: 1, | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/protocol" | 17 | "github.com/bnema/vev/internal/protocol" | ||
| 17 | "github.com/bnema/vev/internal/protocol/catalogue" | 18 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| @@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) { | |||||
| 116 | // daemon with a single session, so Render produces a real frame at any size. | 117 | // daemon with a single session, so Render produces a real frame at any size. | ||
| 117 | func offlineRenderPicker(t *testing.T) *client.Picker { | 118 | func offlineRenderPicker(t *testing.T) *client.Picker { | ||
| 118 | t.Helper() | 119 | t.Helper() | ||
| 119 | - | picker := client.NewPicker(nil, 0, true) | 120 | + | picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) |
| 120 | picker.ApplySnapshot(ports.BrokerSnapshot{ | 121 | picker.ApplySnapshot(ports.BrokerSnapshot{ | ||
| 121 | Epoch: 1, | 122 | Epoch: 1, | ||
| 122 | Revision: 1, | 123 | Revision: 1, | ||
M internal/domain/terminalcap/capabilities.go +41 -23
| @@ -10,9 +10,29 @@ const ( | |||
| 10 | 10 | // TrueColor is the zero value so manually constructed attachments retain the | |
| 11 | 11 | // historical renderer behavior; live Hello paths always use the detector. | |
| 12 | 12 | TrueColor ColorMode = iota | |
| 13 | - | Indexed256 | |
| 13 | + | ANSI256 | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | // ColorCapabilities is the color output capability selected for one attachment | |
| 17 | + | // and how confidently it was selected. | |
| 18 | + | type ColorCapabilities struct { | |
| 19 | + | Mode ColorMode | |
| 20 | + | Source Source | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // RGB reports whether the attachment can receive RGB ANSI output. | |
| 24 | + | func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor } | |
| 25 | + | ||
| 26 | + | // Colors reports the number of colors the attachment can display. | |
| 27 | + | func (c ColorCapabilities) Colors() int { | |
| 28 | + | switch c.Mode { | |
| 29 | + | case ANSI256: | |
| 30 | + | return 256 | |
| 31 | + | default: | |
| 32 | + | return 1 << 24 | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 16 | 36 | // Source records how confidently a terminal capability was selected. | |
| 17 | 37 | type Source uint8 | |
| 18 | 38 | ||
| @@ -33,8 +53,7 @@ const ( | |||
| 33 | 53 | ||
| 34 | 54 | // Capabilities describes the output features selected for one client attachment. | |
| 35 | 55 | type Capabilities struct { | |
| 36 | - | ColorMode ColorMode | |
| 37 | - | ColorSource Source | |
| 56 | + | Color ColorCapabilities | |
| 38 | 57 | Application Application | |
| 39 | 58 | KittyGraphics bool | |
| 40 | 59 | } | |
| @@ -43,15 +62,12 @@ type Capabilities struct { | |||
| 43 | 62 | // accepted the Kitty graphics protocol. Environment detection never sets it. | |
| 44 | 63 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | |
| 45 | 64 | ||
| 46 | - | // TrueColor reports whether this attachment can receive RGB ANSI output. | |
| 47 | - | func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor } | |
| 48 | - | ||
| 49 | 65 | // Detect derives conservative attachment capabilities from a client environment. | |
| 50 | 66 | func Detect(env []string) Capabilities { | |
| 51 | 67 | values := environmentValues(env) | |
| 52 | 68 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | |
| 53 | 69 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | |
| 54 | - | caps := Capabilities{ColorMode: Indexed256} | |
| 70 | + | caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}} | |
| 55 | 71 | ||
| 56 | 72 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | |
| 57 | 73 | caps.Application = ApplicationKitty | |
| @@ -60,25 +76,25 @@ func Detect(env []string) Capabilities { | |||
| 60 | 76 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | |
| 61 | 77 | switch colorTerm { | |
| 62 | 78 | case "truecolor", "24bit": | |
| 63 | - | caps.ColorMode = TrueColor | |
| 64 | - | caps.ColorSource = SourceDeclared | |
| 79 | + | caps.Color.Mode = TrueColor | |
| 80 | + | caps.Color.Source = SourceDeclared | |
| 65 | 81 | } | |
| 66 | 82 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | |
| 67 | - | caps.ColorMode = TrueColor | |
| 68 | - | caps.ColorSource = SourceDeclared | |
| 83 | + | caps.Color.Mode = TrueColor | |
| 84 | + | caps.Color.Source = SourceDeclared | |
| 69 | 85 | } | |
| 70 | 86 | if kittyIdentity { | |
| 71 | - | if caps.ColorSource == SourceUnknown { | |
| 72 | - | caps.ColorSource = SourceHeuristic | |
| 87 | + | if caps.Color.Source == SourceUnknown { | |
| 88 | + | caps.Color.Source = SourceHeuristic | |
| 73 | 89 | } | |
| 74 | - | caps.ColorMode = TrueColor | |
| 90 | + | caps.Color.Mode = TrueColor | |
| 75 | 91 | return caps | |
| 76 | 92 | } | |
| 77 | - | if caps.ColorSource == SourceDeclared { | |
| 93 | + | if caps.Color.Source == SourceDeclared { | |
| 78 | 94 | return caps | |
| 79 | 95 | } | |
| 80 | 96 | if strings.Contains(term, "256color") || term == "dumb" { | |
| 81 | - | caps.ColorSource = SourceDeclared | |
| 97 | + | caps.Color.Source = SourceDeclared | |
| 82 | 98 | } | |
| 83 | 99 | return caps | |
| 84 | 100 | } | |
| @@ -94,11 +110,13 @@ func environmentValues(env []string) map[string]string { | |||
| 94 | 110 | return values | |
| 95 | 111 | } | |
| 96 | 112 | ||
| 97 | - | // DetectTrueColor reports whether the supplied terminal environment advertises | |
| 98 | - | // direct color support. Explicit TERM/COLORTERM values override env entries. | |
| 99 | - | func DetectTrueColor(termEnv, colorTerm string, env []string) bool { | |
| 100 | - | detectionEnv := make([]string, 0, len(env)+2) | |
| 101 | - | detectionEnv = append(detectionEnv, env...) | |
| 102 | - | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | |
| 103 | - | return Detect(detectionEnv).TrueColor() | |
| 113 | + | // Resolve selects attachment capabilities from the client environment and the | |
| 114 | + | // client's explicit color declaration. A declared truecolor client upgrades a | |
| 115 | + | // weaker environment detection to declared TrueColor. | |
| 116 | + | func Resolve(env []string, declaredTrueColor bool) Capabilities { | |
| 117 | + | caps := Detect(env) | |
| 118 | + | if declaredTrueColor && !caps.Color.RGB() { | |
| 119 | + | caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared} | |
| 120 | + | } | |
| 121 | + | return caps | |
| 104 | 122 | } | |
| @@ -10,9 +10,29 @@ const ( | |||||
| 10 | // TrueColor is the zero value so manually constructed attachments retain the | 10 | // TrueColor is the zero value so manually constructed attachments retain the | ||
| 11 | // historical renderer behavior; live Hello paths always use the detector. | 11 | // historical renderer behavior; live Hello paths always use the detector. | ||
| 12 | TrueColor ColorMode = iota | 12 | TrueColor ColorMode = iota | ||
| 13 | - | Indexed256 | 13 | + | ANSI256 |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | // ColorCapabilities is the color output capability selected for one attachment | |||
| 17 | + | // and how confidently it was selected. | |||
| 18 | + | type ColorCapabilities struct { | |||
| 19 | + | Mode ColorMode | |||
| 20 | + | Source Source | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // RGB reports whether the attachment can receive RGB ANSI output. | |||
| 24 | + | func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor } | |||
| 25 | + | ||||
| 26 | + | // Colors reports the number of colors the attachment can display. | |||
| 27 | + | func (c ColorCapabilities) Colors() int { | |||
| 28 | + | switch c.Mode { | |||
| 29 | + | case ANSI256: | |||
| 30 | + | return 256 | |||
| 31 | + | default: | |||
| 32 | + | return 1 << 24 | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 16 | // Source records how confidently a terminal capability was selected. | 36 | // Source records how confidently a terminal capability was selected. | ||
| 17 | type Source uint8 | 37 | type Source uint8 | ||
| 18 | 38 | ||||
| @@ -33,8 +53,7 @@ const ( | |||||
| 33 | 53 | ||||
| 34 | // Capabilities describes the output features selected for one client attachment. | 54 | // Capabilities describes the output features selected for one client attachment. | ||
| 35 | type Capabilities struct { | 55 | type Capabilities struct { | ||
| 36 | - | ColorMode ColorMode | 56 | + | Color ColorCapabilities |
| 37 | - | ColorSource Source | |||
| 38 | Application Application | 57 | Application Application | ||
| 39 | KittyGraphics bool | 58 | KittyGraphics bool | ||
| 40 | } | 59 | } | ||
| @@ -43,15 +62,12 @@ type Capabilities struct { | |||||
| 43 | // accepted the Kitty graphics protocol. Environment detection never sets it. | 62 | // accepted the Kitty graphics protocol. Environment detection never sets it. | ||
| 44 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | 63 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | ||
| 45 | 64 | ||||
| 46 | - | // TrueColor reports whether this attachment can receive RGB ANSI output. | |||
| 47 | - | func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor } | |||
| 48 | - | ||||
| 49 | // Detect derives conservative attachment capabilities from a client environment. | 65 | // Detect derives conservative attachment capabilities from a client environment. | ||
| 50 | func Detect(env []string) Capabilities { | 66 | func Detect(env []string) Capabilities { | ||
| 51 | values := environmentValues(env) | 67 | values := environmentValues(env) | ||
| 52 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | 68 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | ||
| 53 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | 69 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | ||
| 54 | - | caps := Capabilities{ColorMode: Indexed256} | 70 | + | caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}} |
| 55 | 71 | ||||
| 56 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | 72 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | ||
| 57 | caps.Application = ApplicationKitty | 73 | caps.Application = ApplicationKitty | ||
| @@ -60,25 +76,25 @@ func Detect(env []string) Capabilities { | |||||
| 60 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | 76 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | ||
| 61 | switch colorTerm { | 77 | switch colorTerm { | ||
| 62 | case "truecolor", "24bit": | 78 | case "truecolor", "24bit": | ||
| 63 | - | caps.ColorMode = TrueColor | 79 | + | caps.Color.Mode = TrueColor |
| 64 | - | caps.ColorSource = SourceDeclared | 80 | + | caps.Color.Source = SourceDeclared |
| 65 | } | 81 | } | ||
| 66 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | 82 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | ||
| 67 | - | caps.ColorMode = TrueColor | 83 | + | caps.Color.Mode = TrueColor |
| 68 | - | caps.ColorSource = SourceDeclared | 84 | + | caps.Color.Source = SourceDeclared |
| 69 | } | 85 | } | ||
| 70 | if kittyIdentity { | 86 | if kittyIdentity { | ||
| 71 | - | if caps.ColorSource == SourceUnknown { | 87 | + | if caps.Color.Source == SourceUnknown { |
| 72 | - | caps.ColorSource = SourceHeuristic | 88 | + | caps.Color.Source = SourceHeuristic |
| 73 | } | 89 | } | ||
| 74 | - | caps.ColorMode = TrueColor | 90 | + | caps.Color.Mode = TrueColor |
| 75 | return caps | 91 | return caps | ||
| 76 | } | 92 | } | ||
| 77 | - | if caps.ColorSource == SourceDeclared { | 93 | + | if caps.Color.Source == SourceDeclared { |
| 78 | return caps | 94 | return caps | ||
| 79 | } | 95 | } | ||
| 80 | if strings.Contains(term, "256color") || term == "dumb" { | 96 | if strings.Contains(term, "256color") || term == "dumb" { | ||
| 81 | - | caps.ColorSource = SourceDeclared | 97 | + | caps.Color.Source = SourceDeclared |
| 82 | } | 98 | } | ||
| 83 | return caps | 99 | return caps | ||
| 84 | } | 100 | } | ||
| @@ -94,11 +110,13 @@ func environmentValues(env []string) map[string]string { | |||||
| 94 | return values | 110 | return values | ||
| 95 | } | 111 | } | ||
| 96 | 112 | ||||
| 97 | - | // DetectTrueColor reports whether the supplied terminal environment advertises | 113 | + | // Resolve selects attachment capabilities from the client environment and the |
| 98 | - | // direct color support. Explicit TERM/COLORTERM values override env entries. | 114 | + | // client's explicit color declaration. A declared truecolor client upgrades a |
| 99 | - | func DetectTrueColor(termEnv, colorTerm string, env []string) bool { | 115 | + | // weaker environment detection to declared TrueColor. |
| 100 | - | detectionEnv := make([]string, 0, len(env)+2) | 116 | + | func Resolve(env []string, declaredTrueColor bool) Capabilities { |
| 101 | - | detectionEnv = append(detectionEnv, env...) | 117 | + | caps := Detect(env) |
| 102 | - | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | 118 | + | if declaredTrueColor && !caps.Color.RGB() { |
| 103 | - | return Detect(detectionEnv).TrueColor() | 119 | + | caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared} |
| 120 | + | } | |||
| 121 | + | return caps | |||
| 104 | } | 122 | } | ||
M internal/domain/terminalcap/capabilities_test.go +47 -7
| @@ -6,8 +6,48 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) { | |
| 10 | - | require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"})) | |
| 9 | + | func TestColorCapabilities(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | in ColorCapabilities | |
| 13 | + | wantRGB bool | |
| 14 | + | wantColors int | |
| 15 | + | }{ | |
| 16 | + | {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216}, | |
| 17 | + | {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216}, | |
| 18 | + | {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256}, | |
| 19 | + | } | |
| 20 | + | for _, tt := range tests { | |
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |
| 22 | + | require.Equal(t, tt.wantRGB, tt.in.RGB()) | |
| 23 | + | require.Equal(t, tt.wantColors, tt.in.Colors()) | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestResolve(t *testing.T) { | |
| 29 | + | tests := []struct { | |
| 30 | + | name string | |
| 31 | + | env []string | |
| 32 | + | declared bool | |
| 33 | + | want ColorCapabilities | |
| 34 | + | wantApp Application | |
| 35 | + | }{ | |
| 36 | + | {name: "256 color without declaration stays indexed", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |
| 37 | + | {name: "declaration upgrades indexed terminal", env: []string{"TERM=xterm-256color"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 38 | + | {name: "declaration upgrades unknown terminal", env: []string{"TERM=unknown"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 39 | + | {name: "declaration keeps environment truecolor detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty}, | |
| 40 | + | {name: "environment truecolor without declaration", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 41 | + | {name: "declaration keeps application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty}, | |
| 42 | + | } | |
| 43 | + | for _, tt := range tests { | |
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |
| 45 | + | got := Resolve(tt.env, tt.declared) | |
| 46 | + | require.Equal(t, tt.want, got.Color) | |
| 47 | + | require.Equal(t, tt.wantApp, got.Application) | |
| 48 | + | require.False(t, got.KittyGraphics) | |
| 49 | + | }) | |
| 50 | + | } | |
| 11 | 51 | } | |
| 12 | 52 | ||
| 13 | 53 | func TestDetect(t *testing.T) { | |
| @@ -24,16 +64,16 @@ func TestDetect(t *testing.T) { | |||
| 24 | 64 | {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |
| 25 | 65 | {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | |
| 26 | 66 | {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | |
| 27 | - | {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared}, | |
| 28 | - | {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown}, | |
| 29 | - | {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | |
| 67 | + | {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |
| 68 | + | {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 69 | + | {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | |
| 30 | 70 | } | |
| 31 | 71 | ||
| 32 | 72 | for _, tt := range tests { | |
| 33 | 73 | t.Run(tt.name, func(t *testing.T) { | |
| 34 | 74 | got := Detect(tt.env) | |
| 35 | - | require.Equal(t, tt.wantMode, got.ColorMode) | |
| 36 | - | require.Equal(t, tt.wantSource, got.ColorSource) | |
| 75 | + | require.Equal(t, tt.wantMode, got.Color.Mode) | |
| 76 | + | require.Equal(t, tt.wantSource, got.Color.Source) | |
| 37 | 77 | require.Equal(t, tt.wantApp, got.Application) | |
| 38 | 78 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | |
| 39 | 79 | }) | |
| @@ -6,8 +6,48 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) { | 9 | + | func TestColorCapabilities(t *testing.T) { |
| 10 | - | require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"})) | 10 | + | tests := []struct { |
| 11 | + | name string | |||
| 12 | + | in ColorCapabilities | |||
| 13 | + | wantRGB bool | |||
| 14 | + | wantColors int | |||
| 15 | + | }{ | |||
| 16 | + | {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216}, | |||
| 17 | + | {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216}, | |||
| 18 | + | {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256}, | |||
| 19 | + | } | |||
| 20 | + | for _, tt := range tests { | |||
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 22 | + | require.Equal(t, tt.wantRGB, tt.in.RGB()) | |||
| 23 | + | require.Equal(t, tt.wantColors, tt.in.Colors()) | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestResolve(t *testing.T) { | |||
| 29 | + | tests := []struct { | |||
| 30 | + | name string | |||
| 31 | + | env []string | |||
| 32 | + | declared bool | |||
| 33 | + | want ColorCapabilities | |||
| 34 | + | wantApp Application | |||
| 35 | + | }{ | |||
| 36 | + | {name: "256 color without declaration stays indexed", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |||
| 37 | + | {name: "declaration upgrades indexed terminal", env: []string{"TERM=xterm-256color"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 38 | + | {name: "declaration upgrades unknown terminal", env: []string{"TERM=unknown"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 39 | + | {name: "declaration keeps environment truecolor detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty}, | |||
| 40 | + | {name: "environment truecolor without declaration", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 41 | + | {name: "declaration keeps application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty}, | |||
| 42 | + | } | |||
| 43 | + | for _, tt := range tests { | |||
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 45 | + | got := Resolve(tt.env, tt.declared) | |||
| 46 | + | require.Equal(t, tt.want, got.Color) | |||
| 47 | + | require.Equal(t, tt.wantApp, got.Application) | |||
| 48 | + | require.False(t, got.KittyGraphics) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 11 | } | 51 | } | ||
| 12 | 52 | ||||
| 13 | func TestDetect(t *testing.T) { | 53 | func TestDetect(t *testing.T) { | ||
| @@ -24,16 +64,16 @@ func TestDetect(t *testing.T) { | |||||
| 24 | {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | 64 | {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | ||
| 25 | {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | 65 | {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | ||
| 26 | {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | 66 | {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | ||
| 27 | - | {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared}, | 67 | + | {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, |
| 28 | - | {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown}, | 68 | + | {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown}, |
| 29 | - | {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty}, | 69 | + | {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty}, |
| 30 | } | 70 | } | ||
| 31 | 71 | ||||
| 32 | for _, tt := range tests { | 72 | for _, tt := range tests { | ||
| 33 | t.Run(tt.name, func(t *testing.T) { | 73 | t.Run(tt.name, func(t *testing.T) { | ||
| 34 | got := Detect(tt.env) | 74 | got := Detect(tt.env) | ||
| 35 | - | require.Equal(t, tt.wantMode, got.ColorMode) | 75 | + | require.Equal(t, tt.wantMode, got.Color.Mode) |
| 36 | - | require.Equal(t, tt.wantSource, got.ColorSource) | 76 | + | require.Equal(t, tt.wantSource, got.Color.Source) |
| 37 | require.Equal(t, tt.wantApp, got.Application) | 77 | require.Equal(t, tt.wantApp, got.Application) | ||
| 38 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | 78 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | ||
| 39 | }) | 79 | }) | ||
M internal/usecase/client/attachment_input.go +1 -1
| @@ -161,7 +161,7 @@ func (in *attachmentInput) start(ctx context.Context) error { | |||
| 161 | 161 | if in.palette == nil { | |
| 162 | 162 | return nil | |
| 163 | 163 | } | |
| 164 | - | retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor }) | |
| 164 | + | retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.Color.RGB() }) | |
| 165 | 165 | return in.apply(ctx, in.palette.start(retained, false)) | |
| 166 | 166 | } | |
| 167 | 167 | ||
| @@ -161,7 +161,7 @@ func (in *attachmentInput) start(ctx context.Context) error { | |||||
| 161 | if in.palette == nil { | 161 | if in.palette == nil { | ||
| 162 | return nil | 162 | return nil | ||
| 163 | } | 163 | } | ||
| 164 | - | retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor }) | 164 | + | retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.Color.RGB() }) |
| 165 | return in.apply(ctx, in.palette.start(retained, false)) | 165 | return in.apply(ctx, in.palette.start(retained, false)) | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
M internal/usecase/client/attachment_input_test.go +2 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | renderer "github.com/bnema/vev-vt" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol" | |
| @@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque | |||
| 67 | 68 | cfg.Clock = h.clock | |
| 68 | 69 | cfg.Theme = themes | |
| 69 | 70 | cfg.Clipboard = clipboard | |
| 70 | - | cfg.TrueColor = true | |
| 71 | + | cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor} | |
| 71 | 72 | worker, err := newSessionAttachmentWorker(cfg) | |
| 72 | 73 | require.NoError(t, err) | |
| 73 | 74 | go func() { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | renderer "github.com/bnema/vev-vt" | 12 | renderer "github.com/bnema/vev-vt" | ||
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 15 | "github.com/bnema/vev/internal/protocol" | 16 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque | |||||
| 67 | cfg.Clock = h.clock | 68 | cfg.Clock = h.clock | ||
| 68 | cfg.Theme = themes | 69 | cfg.Theme = themes | ||
| 69 | cfg.Clipboard = clipboard | 70 | cfg.Clipboard = clipboard | ||
| 70 | - | cfg.TrueColor = true | 71 | + | cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor} |
| 71 | worker, err := newSessionAttachmentWorker(cfg) | 72 | worker, err := newSessionAttachmentWorker(cfg) | ||
| 72 | require.NoError(t, err) | 73 | require.NoError(t, err) | ||
| 73 | go func() { | 74 | go func() { | ||
M internal/usecase/client/attachment_session.go +9 -8
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 14 | 15 | ) | |
| @@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string { | |||
| 68 | 69 | // The request is the exact request the supervisor opened the stream with; the | |
| 69 | 70 | // rest is client-local presentation data the worker mirrors into Hello. | |
| 70 | 71 | type sessionAttachmentConfig struct { | |
| 71 | - | Request ports.BrokerOpenStreamRequest | |
| 72 | - | ClientID [16]byte | |
| 73 | - | Geometry domain.Geometry | |
| 74 | - | TermEnv string | |
| 75 | - | Cwd string | |
| 76 | - | TrueColor bool | |
| 72 | + | Request ports.BrokerOpenStreamRequest | |
| 73 | + | ClientID [16]byte | |
| 74 | + | Geometry domain.Geometry | |
| 75 | + | TermEnv string | |
| 76 | + | Cwd string | |
| 77 | + | Color terminalcap.ColorCapabilities | |
| 77 | 78 | // PID is this client's process ID; Hello carries it on local attaches. | |
| 78 | 79 | PID uint32 | |
| 79 | 80 | // Capabilities are the outer terminal's probed capabilities, declared in | |
| @@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 322 | 323 | var samePeerRequests uint64 | |
| 323 | 324 | var samePeerSwitch *samePeerSwitchPending | |
| 324 | 325 | samePeerUI := attachmentSamePeerUI(fg) | |
| 325 | - | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 326 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} | |
| 326 | 327 | defer picker.stopEscape() | |
| 327 | 328 | input := newAttachmentInput(ctx, w, fg, stream, picker) | |
| 328 | 329 | defer input.close() | |
| @@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 751 | 752 | PixelHeight: geometry.PixelHeight, | |
| 752 | 753 | TermEnv: w.cfg.TermEnv, | |
| 753 | 754 | Cwd: cwd, | |
| 754 | - | TrueColor: w.cfg.TrueColor, | |
| 755 | + | TrueColor: w.cfg.Color.RGB(), | |
| 755 | 756 | // A remote attach reaches the terminal through this client, so both | |
| 756 | 757 | // declarations describe this client's outer terminal. | |
| 757 | 758 | KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics, | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 12 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 13 | "github.com/bnema/vev/internal/protocol" | 14 | "github.com/bnema/vev/internal/protocol" | ||
| 14 | ) | 15 | ) | ||
| @@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string { | |||||
| 68 | // The request is the exact request the supervisor opened the stream with; the | 69 | // The request is the exact request the supervisor opened the stream with; the | ||
| 69 | // rest is client-local presentation data the worker mirrors into Hello. | 70 | // rest is client-local presentation data the worker mirrors into Hello. | ||
| 70 | type sessionAttachmentConfig struct { | 71 | type sessionAttachmentConfig struct { | ||
| 71 | - | Request ports.BrokerOpenStreamRequest | 72 | + | Request ports.BrokerOpenStreamRequest |
| 72 | - | ClientID [16]byte | 73 | + | ClientID [16]byte |
| 73 | - | Geometry domain.Geometry | 74 | + | Geometry domain.Geometry |
| 74 | - | TermEnv string | 75 | + | TermEnv string |
| 75 | - | Cwd string | 76 | + | Cwd string |
| 76 | - | TrueColor bool | 77 | + | Color terminalcap.ColorCapabilities |
| 77 | // PID is this client's process ID; Hello carries it on local attaches. | 78 | // PID is this client's process ID; Hello carries it on local attaches. | ||
| 78 | PID uint32 | 79 | PID uint32 | ||
| 79 | // Capabilities are the outer terminal's probed capabilities, declared in | 80 | // Capabilities are the outer terminal's probed capabilities, declared in | ||
| @@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 322 | var samePeerRequests uint64 | 323 | var samePeerRequests uint64 | ||
| 323 | var samePeerSwitch *samePeerSwitchPending | 324 | var samePeerSwitch *samePeerSwitchPending | ||
| 324 | samePeerUI := attachmentSamePeerUI(fg) | 325 | samePeerUI := attachmentSamePeerUI(fg) | ||
| 325 | - | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | 326 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} |
| 326 | defer picker.stopEscape() | 327 | defer picker.stopEscape() | ||
| 327 | input := newAttachmentInput(ctx, w, fg, stream, picker) | 328 | input := newAttachmentInput(ctx, w, fg, stream, picker) | ||
| 328 | defer input.close() | 329 | defer input.close() | ||
| @@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||||
| 751 | PixelHeight: geometry.PixelHeight, | 752 | PixelHeight: geometry.PixelHeight, | ||
| 752 | TermEnv: w.cfg.TermEnv, | 753 | TermEnv: w.cfg.TermEnv, | ||
| 753 | Cwd: cwd, | 754 | Cwd: cwd, | ||
| 754 | - | TrueColor: w.cfg.TrueColor, | 755 | + | TrueColor: w.cfg.Color.RGB(), |
| 755 | // A remote attach reaches the terminal through this client, so both | 756 | // A remote attach reaches the terminal through this client, so both | ||
| 756 | // declarations describe this client's outer terminal. | 757 | // declarations describe this client's outer terminal. | ||
| 757 | KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics, | 758 | KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics, | ||
M internal/usecase/client/attachment_session_test.go +6 -3
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | ) | |
| @@ -528,9 +529,11 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 528 | 529 | tests := []struct { | |
| 529 | 530 | name string | |
| 530 | 531 | env AttachmentEnvironment | |
| 532 | + | want bool | |
| 531 | 533 | }{ | |
| 532 | - | {name: "unset"}, | |
| 533 | - | {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}}, | |
| 534 | + | {name: "unset defaults to truecolor", want: true}, | |
| 535 | + | {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, want: true}, | |
| 536 | + | {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}}, | |
| 534 | 537 | } | |
| 535 | 538 | for _, tt := range tests { | |
| 536 | 539 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -551,7 +554,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 551 | 554 | hello := awaitHello(t, stream) | |
| 552 | 555 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | |
| 553 | 556 | require.Equal(t, tt.env.Cwd, hello.Cwd) | |
| 554 | - | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | |
| 557 | + | require.Equal(t, tt.want, hello.TrueColor) | |
| 555 | 558 | require.NoError(t, stream.Close()) | |
| 556 | 559 | <-events | |
| 557 | 560 | }) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/protocol" | 17 | "github.com/bnema/vev/internal/protocol" | ||
| 17 | ) | 18 | ) | ||
| @@ -528,9 +529,11 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||||
| 528 | tests := []struct { | 529 | tests := []struct { | ||
| 529 | name string | 530 | name string | ||
| 530 | env AttachmentEnvironment | 531 | env AttachmentEnvironment | ||
| 532 | + | want bool | |||
| 531 | }{ | 533 | }{ | ||
| 532 | - | {name: "unset"}, | 534 | + | {name: "unset defaults to truecolor", want: true}, |
| 533 | - | {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}}, | 535 | + | {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, want: true}, |
| 536 | + | {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}}, | |||
| 534 | } | 537 | } | ||
| 535 | for _, tt := range tests { | 538 | for _, tt := range tests { | ||
| 536 | t.Run(tt.name, func(t *testing.T) { | 539 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -551,7 +554,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||||
| 551 | hello := awaitHello(t, stream) | 554 | hello := awaitHello(t, stream) | ||
| 552 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | 555 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | ||
| 553 | require.Equal(t, tt.env.Cwd, hello.Cwd) | 556 | require.Equal(t, tt.env.Cwd, hello.Cwd) | ||
| 554 | - | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | 557 | + | require.Equal(t, tt.want, hello.TrueColor) |
| 555 | require.NoError(t, stream.Close()) | 558 | require.NoError(t, stream.Close()) | ||
| 556 | <-events | 559 | <-events | ||
| 557 | }) | 560 | }) | ||
M internal/usecase/client/move_picker_overlay.go +4 -2
| @@ -6,8 +6,10 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |
| 11 | 13 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 12 | 14 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | 15 | ) | |
| @@ -40,8 +42,8 @@ type movePickerOverlay struct { | |||
| 40 | 42 | presenting bool | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | - | func newMovePickerOverlay(trueColor bool) *movePickerOverlay { | |
| 44 | - | return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))} | |
| 45 | + | func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay { | |
| 46 | + | return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))} | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 47 | 49 | // offer opens the namespace a move offer names. A superseding offer retires | |
| @@ -6,8 +6,10 @@ import ( | |||||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 12 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |||
| 11 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | 13 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | ||
| 12 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | 14 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | ||
| 13 | ) | 15 | ) | ||
| @@ -40,8 +42,8 @@ type movePickerOverlay struct { | |||||
| 40 | presenting bool | 42 | presenting bool | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 43 | - | func newMovePickerOverlay(trueColor bool) *movePickerOverlay { | 45 | + | func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay { |
| 44 | - | return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))} | 46 | + | return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))} |
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 47 | // offer opens the namespace a move offer names. A superseding offer retires | 49 | // offer opens the namespace a move offer names. A superseding offer retires | ||
M internal/usecase/client/picker_composition.go +5 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| @@ -49,11 +50,11 @@ func PickerPresentation(state State) bool { | |||
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | 52 | // NewPicker composes one client-owned picker. A nil clock uses the system | |
| 52 | - | // clock, and a zero freshness uses the catalogue default. trueColor is the | |
| 53 | - | // terminal's detected color capability (AttachmentEnvironment.TrueColor); the | |
| 53 | + | // clock, and a zero freshness uses the catalogue default. color is the | |
| 54 | + | // terminal's detected color capability (AttachmentEnvironment.Color); the | |
| 54 | 55 | // picker renders indexed colors without it. | |
| 55 | - | func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker { | |
| 56 | - | return &Picker{controller: newPickerController(clock, freshness, trueColor)} | |
| 56 | + | func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker { | |
| 57 | + | return &Picker{controller: newPickerController(clock, freshness, color)} | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/protocol" | 9 | "github.com/bnema/vev/internal/protocol" | ||
| 9 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | 10 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | ||
| @@ -49,11 +50,11 @@ func PickerPresentation(state State) bool { | |||||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | // NewPicker composes one client-owned picker. A nil clock uses the system | 52 | // NewPicker composes one client-owned picker. A nil clock uses the system | ||
| 52 | - | // clock, and a zero freshness uses the catalogue default. trueColor is the | 53 | + | // clock, and a zero freshness uses the catalogue default. color is the |
| 53 | - | // terminal's detected color capability (AttachmentEnvironment.TrueColor); the | 54 | + | // terminal's detected color capability (AttachmentEnvironment.Color); the |
| 54 | // picker renders indexed colors without it. | 55 | // picker renders indexed colors without it. | ||
| 55 | - | func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker { | 56 | + | func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker { |
| 56 | - | return &Picker{controller: newPickerController(clock, freshness, trueColor)} | 57 | + | return &Picker{controller: newPickerController(clock, freshness, color)} |
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | 60 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | ||
M internal/usecase/client/picker_composition_external_test.go +2 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| @@ -19,7 +20,7 @@ import ( | |||
| 19 | 20 | // so it could never resolve a selection into an attachment. | |
| 20 | 21 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | |
| 21 | 22 | geometry := domain.Size{Cols: 80, Rows: 24} | |
| 22 | - | picker := client.NewPicker(nil, 0, true) | |
| 23 | + | picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) | |
| 23 | 24 | require.NotNil(t, picker) | |
| 24 | 25 | ||
| 25 | 26 | // The exact assignment an app composition performs. | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/protocol" | 12 | "github.com/bnema/vev/internal/protocol" | ||
| 12 | "github.com/bnema/vev/internal/protocol/catalogue" | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| @@ -19,7 +20,7 @@ import ( | |||||
| 19 | // so it could never resolve a selection into an attachment. | 20 | // so it could never resolve a selection into an attachment. | ||
| 20 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | 21 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | ||
| 21 | geometry := domain.Size{Cols: 80, Rows: 24} | 22 | geometry := domain.Size{Cols: 80, Rows: 24} | ||
| 22 | - | picker := client.NewPicker(nil, 0, true) | 23 | + | picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) |
| 23 | require.NotNil(t, picker) | 24 | require.NotNil(t, picker) | ||
| 24 | 25 | ||||
| 25 | // The exact assignment an app composition performs. | 26 | // The exact assignment an app composition performs. | ||
M internal/usecase/client/picker_controller.go +4 -2
| @@ -8,9 +8,11 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |
| 14 | 16 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 17 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | 18 | ) | |
| @@ -123,14 +125,14 @@ type pickerController struct { | |||
| 123 | 125 | // newPickerController builds a picker over an empty catalogue. The picker owns | |
| 124 | 126 | // input from the start: the controller presents it for the whole picker presentation and | |
| 125 | 127 | // has no session pipeline to defer to. | |
| 126 | - | func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController { | |
| 128 | + | func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController { | |
| 127 | 129 | if supervisorNil(clock) { | |
| 128 | 130 | clock = systemClock{} | |
| 129 | 131 | } | |
| 130 | 132 | controller := &pickerController{ | |
| 131 | 133 | clock: clock, | |
| 132 | 134 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 133 | - | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | |
| 135 | + | renderer: newPickerRenderer(colorprofile.Profile(color)), | |
| 134 | 136 | sort: defaultPickerSort(), | |
| 135 | 137 | notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}), | |
| 136 | 138 | ownsInput: true, | |
| @@ -8,9 +8,11 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | "github.com/bnema/vev/internal/protocol" | 13 | "github.com/bnema/vev/internal/protocol" | ||
| 13 | "github.com/bnema/vev/internal/protocol/catalogue" | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |||
| 14 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | 16 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | ||
| 15 | "github.com/bnema/vev/internal/usecase/ui" | 17 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 16 | ) | 18 | ) | ||
| @@ -123,14 +125,14 @@ type pickerController struct { | |||||
| 123 | // newPickerController builds a picker over an empty catalogue. The picker owns | 125 | // newPickerController builds a picker over an empty catalogue. The picker owns | ||
| 124 | // input from the start: the controller presents it for the whole picker presentation and | 126 | // input from the start: the controller presents it for the whole picker presentation and | ||
| 125 | // has no session pipeline to defer to. | 127 | // has no session pipeline to defer to. | ||
| 126 | - | func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController { | 128 | + | func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController { |
| 127 | if supervisorNil(clock) { | 129 | if supervisorNil(clock) { | ||
| 128 | clock = systemClock{} | 130 | clock = systemClock{} | ||
| 129 | } | 131 | } | ||
| 130 | controller := &pickerController{ | 132 | controller := &pickerController{ | ||
| 131 | clock: clock, | 133 | clock: clock, | ||
| 132 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | 134 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | ||
| 133 | - | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | 135 | + | renderer: newPickerRenderer(colorprofile.Profile(color)), |
| 134 | sort: defaultPickerSort(), | 136 | sort: defaultPickerSort(), | ||
| 135 | notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}), | 137 | notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}), | ||
| 136 | ownsInput: true, | 138 | ownsInput: true, | ||
M internal/usecase/client/picker_controller_test.go +4 -3
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, | |||
| 118 | 119 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | |
| 119 | 120 | t.Helper() | |
| 120 | 121 | clock := newSupervisorTestClock() | |
| 121 | - | return newPickerController(clock, pickerTestFreshness, true), clock | |
| 122 | + | return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock | |
| 122 | 123 | } | |
| 123 | 124 | ||
| 124 | 125 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | |
| @@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||
| 428 | 429 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 429 | 430 | return service, nil | |
| 430 | 431 | }) | |
| 431 | - | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 432 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) | |
| 432 | 433 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 433 | 434 | ||
| 434 | 435 | runErr := make(chan error, 1) | |
| @@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||
| 473 | 474 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 474 | 475 | return service, nil | |
| 475 | 476 | }) | |
| 476 | - | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 477 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) | |
| 477 | 478 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 478 | 479 | ||
| 479 | 480 | runErr := make(chan error, 1) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/protocol/catalogue" | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 17 | "github.com/bnema/vev/internal/usecase/ui" | 18 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, | |||||
| 118 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | 119 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | ||
| 119 | t.Helper() | 120 | t.Helper() | ||
| 120 | clock := newSupervisorTestClock() | 121 | clock := newSupervisorTestClock() | ||
| 121 | - | return newPickerController(clock, pickerTestFreshness, true), clock | 122 | + | return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock |
| 122 | } | 123 | } | ||
| 123 | 124 | ||||
| 124 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | 125 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | ||
| @@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||||
| 428 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | 429 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | ||
| 429 | return service, nil | 430 | return service, nil | ||
| 430 | }) | 431 | }) | ||
| 431 | - | controller := newPickerController(clock, pickerTestFreshness, true) | 432 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) |
| 432 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | 433 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | ||
| 433 | 434 | ||||
| 434 | runErr := make(chan error, 1) | 435 | runErr := make(chan error, 1) | ||
| @@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||||
| 473 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | 474 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | ||
| 474 | return service, nil | 475 | return service, nil | ||
| 475 | }) | 476 | }) | ||
| 476 | - | controller := newPickerController(clock, pickerTestFreshness, true) | 477 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) |
| 477 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | 478 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | ||
| 478 | 479 | ||||
| 479 | runErr := make(chan error, 1) | 480 | runErr := make(chan error, 1) | ||
M internal/usecase/client/picker_interaction.go +0 -10
| @@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { | |||
| 340 | 340 | return &pickerRenderer{profile: profile} | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | - | // pickerColorProfile maps the terminal's detected color capability to the | |
| 344 | - | // picker renderer profile. A terminal without truecolor gets indexed colors, | |
| 345 | - | // so RGB surfaces are quantized instead of dropped (#280). | |
| 346 | - | func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile { | |
| 347 | - | if trueColor { | |
| 348 | - | return ansirenderer.ColorProfileTrueColor | |
| 349 | - | } | |
| 350 | - | return ansirenderer.ColorProfileANSI256 | |
| 351 | - | } | |
| 352 | - | ||
| 353 | 343 | // render composes the loop model into terminal bytes for one display | |
| 354 | 344 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | |
| 355 | 345 | // to the model by the caller; a full redraw every refresh keeps the shadow | |
| @@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { | |||||
| 340 | return &pickerRenderer{profile: profile} | 340 | return &pickerRenderer{profile: profile} | ||
| 341 | } | 341 | } | ||
| 342 | 342 | ||||
| 343 | - | // pickerColorProfile maps the terminal's detected color capability to the | |||
| 344 | - | // picker renderer profile. A terminal without truecolor gets indexed colors, | |||
| 345 | - | // so RGB surfaces are quantized instead of dropped (#280). | |||
| 346 | - | func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile { | |||
| 347 | - | if trueColor { | |||
| 348 | - | return ansirenderer.ColorProfileTrueColor | |||
| 349 | - | } | |||
| 350 | - | return ansirenderer.ColorProfileANSI256 | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | // render composes the loop model into terminal bytes for one display | 343 | // render composes the loop model into terminal bytes for one display | ||
| 354 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | 344 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | ||
| 355 | // to the model by the caller; a full redraw every refresh keeps the shadow | 345 | // to the model by the caller; a full redraw every refresh keeps the shadow | ||
M internal/usecase/client/picker_interaction_test.go +11 -10
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | 14 | "github.com/stretchr/testify/require" | |
| @@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) { | |||
| 73 | 74 | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(), | |
| 74 | 75 | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | |
| 75 | 76 | } | |
| 76 | - | renderers := map[string]func(trueColor bool) *pickerRenderer{ | |
| 77 | - | "session picker": func(trueColor bool) *pickerRenderer { | |
| 78 | - | return NewPicker(nil, 0, trueColor).controller.renderer | |
| 77 | + | renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{ | |
| 78 | + | "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer { | |
| 79 | + | return NewPicker(nil, 0, color).controller.renderer | |
| 79 | 80 | }, | |
| 80 | - | "move picker": func(trueColor bool) *pickerRenderer { | |
| 81 | - | return newMovePickerOverlay(trueColor).renderer | |
| 81 | + | "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer { | |
| 82 | + | return newMovePickerOverlay(color).renderer | |
| 82 | 83 | }, | |
| 83 | 84 | } | |
| 84 | 85 | for name, build := range renderers { | |
| 85 | 86 | for _, tc := range []struct { | |
| 86 | - | trueColor bool | |
| 87 | + | color terminalcap.ColorCapabilities | |
| 87 | 88 | profile ansirenderer.ColorProfile | |
| 88 | 89 | want string | |
| 89 | 90 | forbidden string | |
| 90 | 91 | }{ | |
| 91 | - | {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, | |
| 92 | - | {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, | |
| 92 | + | {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, | |
| 93 | + | {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, | |
| 93 | 94 | } { | |
| 94 | - | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) { | |
| 95 | - | r := build(tc.trueColor) | |
| 95 | + | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) { | |
| 96 | + | r := build(tc.color) | |
| 96 | 97 | require.Equal(t, tc.profile, r.profile) | |
| 97 | 98 | r.renderStyles = []picker.RenderStyles{styles} | |
| 98 | 99 | output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | ansirenderer "github.com/bnema/vev-vt/ansi" | 9 | ansirenderer "github.com/bnema/vev-vt/ansi" | ||
| 10 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 11 | "github.com/bnema/vev/internal/protocol" | 12 | "github.com/bnema/vev/internal/protocol" | ||
| 12 | "github.com/bnema/vev/internal/usecase/picker" | 13 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 13 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| @@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) { | |||||
| 73 | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(), | 74 | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(), | ||
| 74 | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | 75 | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | ||
| 75 | } | 76 | } | ||
| 76 | - | renderers := map[string]func(trueColor bool) *pickerRenderer{ | 77 | + | renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{ |
| 77 | - | "session picker": func(trueColor bool) *pickerRenderer { | 78 | + | "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer { |
| 78 | - | return NewPicker(nil, 0, trueColor).controller.renderer | 79 | + | return NewPicker(nil, 0, color).controller.renderer |
| 79 | }, | 80 | }, | ||
| 80 | - | "move picker": func(trueColor bool) *pickerRenderer { | 81 | + | "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer { |
| 81 | - | return newMovePickerOverlay(trueColor).renderer | 82 | + | return newMovePickerOverlay(color).renderer |
| 82 | }, | 83 | }, | ||
| 83 | } | 84 | } | ||
| 84 | for name, build := range renderers { | 85 | for name, build := range renderers { | ||
| 85 | for _, tc := range []struct { | 86 | for _, tc := range []struct { | ||
| 86 | - | trueColor bool | 87 | + | color terminalcap.ColorCapabilities |
| 87 | profile ansirenderer.ColorProfile | 88 | profile ansirenderer.ColorProfile | ||
| 88 | want string | 89 | want string | ||
| 89 | forbidden string | 90 | forbidden string | ||
| 90 | }{ | 91 | }{ | ||
| 91 | - | {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, | 92 | + | {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, |
| 92 | - | {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, | 93 | + | {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, |
| 93 | } { | 94 | } { | ||
| 94 | - | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) { | 95 | + | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) { |
| 95 | - | r := build(tc.trueColor) | 96 | + | r := build(tc.color) |
| 96 | require.Equal(t, tc.profile, r.profile) | 97 | require.Equal(t, tc.profile, r.profile) | ||
| 97 | r.renderStyles = []picker.RenderStyles{styles} | 98 | r.renderStyles = []picker.RenderStyles{styles} | ||
| 98 | output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | 99 | output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | ||
M internal/usecase/client/supervisor.go +6 -3
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 18 | 19 | ) | |
| @@ -382,9 +383,11 @@ type SupervisorConfig struct { | |||
| 382 | 383 | // AttachmentEnvironment is the composition seam for client environment data | |
| 383 | 384 | // carried by attachment Hello messages. | |
| 384 | 385 | type AttachmentEnvironment struct { | |
| 385 | - | TermEnv string | |
| 386 | - | Cwd string | |
| 387 | - | TrueColor bool | |
| 386 | + | TermEnv string | |
| 387 | + | Cwd string | |
| 388 | + | // Color is the terminal's detected color capability. The zero value is | |
| 389 | + | // TrueColor; composition fills it from terminal detection. | |
| 390 | + | Color terminalcap.ColorCapabilities | |
| 388 | 391 | // PID is this client's process ID, sent on local attaches only. | |
| 389 | 392 | PID uint32 | |
| 390 | 393 | // ProbeTerminal enables the one bounded capability probe of a real outer | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 16 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | 18 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | ||
| 18 | ) | 19 | ) | ||
| @@ -382,9 +383,11 @@ type SupervisorConfig struct { | |||||
| 382 | // AttachmentEnvironment is the composition seam for client environment data | 383 | // AttachmentEnvironment is the composition seam for client environment data | ||
| 383 | // carried by attachment Hello messages. | 384 | // carried by attachment Hello messages. | ||
| 384 | type AttachmentEnvironment struct { | 385 | type AttachmentEnvironment struct { | ||
| 385 | - | TermEnv string | 386 | + | TermEnv string |
| 386 | - | Cwd string | 387 | + | Cwd string |
| 387 | - | TrueColor bool | 388 | + | // Color is the terminal's detected color capability. The zero value is |
| 389 | + | // TrueColor; composition fills it from terminal detection. | |||
| 390 | + | Color terminalcap.ColorCapabilities | |||
| 388 | // PID is this client's process ID, sent on local attaches only. | 391 | // PID is this client's process ID, sent on local attaches only. | ||
| 389 | PID uint32 | 392 | PID uint32 | ||
| 390 | // ProbeTerminal enables the one bounded capability probe of a real outer | 393 | // ProbeTerminal enables the one bounded capability probe of a real outer | ||
M internal/usecase/client/supervisor_attach.go +1 -1
| @@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 347 | 347 | Geometry: geometry, | |
| 348 | 348 | TermEnv: environment.TermEnv, | |
| 349 | 349 | Cwd: environment.Cwd, | |
| 350 | - | TrueColor: environment.TrueColor, | |
| 350 | + | Color: environment.Color, | |
| 351 | 351 | PID: environment.PID, | |
| 352 | 352 | Capabilities: s.capabilities, | |
| 353 | 353 | SessionEnvironment: sessionEnv, | |
| @@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 347 | Geometry: geometry, | 347 | Geometry: geometry, | ||
| 348 | TermEnv: environment.TermEnv, | 348 | TermEnv: environment.TermEnv, | ||
| 349 | Cwd: environment.Cwd, | 349 | Cwd: environment.Cwd, | ||
| 350 | - | TrueColor: environment.TrueColor, | 350 | + | Color: environment.Color, |
| 351 | PID: environment.PID, | 351 | PID: environment.PID, | ||
| 352 | Capabilities: s.capabilities, | 352 | Capabilities: s.capabilities, | ||
| 353 | SessionEnvironment: sessionEnv, | 353 | SessionEnvironment: sessionEnv, | ||
M internal/usecase/client/supervisor_attach_test.go +2 -1
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol" | |
| 19 | 20 | ) | |
| @@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |||
| 1634 | 1635 | return admitted, nil | |
| 1635 | 1636 | }) | |
| 1636 | 1637 | ||
| 1637 | - | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 1638 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) | |
| 1638 | 1639 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }) | |
| 1639 | 1640 | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1640 | 1641 | Connector: connector, | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 17 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/protocol" | 19 | "github.com/bnema/vev/internal/protocol" | ||
| 19 | ) | 20 | ) | ||
| @@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |||||
| 1634 | return admitted, nil | 1635 | return admitted, nil | ||
| 1635 | }) | 1636 | }) | ||
| 1636 | 1637 | ||||
| 1637 | - | controller := newPickerController(clock, pickerTestFreshness, true) | 1638 | + | controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}) |
| 1638 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }) | 1639 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }) | ||
| 1639 | sup := mustSupervisor(t, SupervisorConfig{ | 1640 | sup := mustSupervisor(t, SupervisorConfig{ | ||
| 1640 | Connector: connector, | 1641 | Connector: connector, | ||
M internal/usecase/client/supervisor_test.go +3 -2
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) { | |||
| 715 | 716 | }), | |
| 716 | 717 | Terminal: newSupervisorTestTerminal(reader), | |
| 717 | 718 | Clock: clock, | |
| 718 | - | Picker: newPickerController(clock, pickerTestFreshness, true), | |
| 719 | + | Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), | |
| 719 | 720 | Render: func(state State) { | |
| 720 | 721 | renders <- state | |
| 721 | 722 | }, | |
| @@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||
| 1494 | 1495 | }) | |
| 1495 | 1496 | s := mustSupervisor(t, SupervisorConfig{ | |
| 1496 | 1497 | Connector: connector, Terminal: terminal, Clock: clock, | |
| 1497 | - | Picker: NewPicker(clock, 0, true), | |
| 1498 | + | Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), | |
| 1498 | 1499 | Notify: func(State, error) { reached <- struct{}{} }, | |
| 1499 | 1500 | }) | |
| 1500 | 1501 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 17 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) { | |||||
| 715 | }), | 716 | }), | ||
| 716 | Terminal: newSupervisorTestTerminal(reader), | 717 | Terminal: newSupervisorTestTerminal(reader), | ||
| 717 | Clock: clock, | 718 | Clock: clock, | ||
| 718 | - | Picker: newPickerController(clock, pickerTestFreshness, true), | 719 | + | Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), |
| 719 | Render: func(state State) { | 720 | Render: func(state State) { | ||
| 720 | renders <- state | 721 | renders <- state | ||
| 721 | }, | 722 | }, | ||
| @@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||||
| 1494 | }) | 1495 | }) | ||
| 1495 | s := mustSupervisor(t, SupervisorConfig{ | 1496 | s := mustSupervisor(t, SupervisorConfig{ | ||
| 1496 | Connector: connector, Terminal: terminal, Clock: clock, | 1497 | Connector: connector, Terminal: terminal, Clock: clock, | ||
| 1497 | - | Picker: NewPicker(clock, 0, true), | 1498 | + | Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), |
| 1498 | Notify: func(State, error) { reached <- struct{}{} }, | 1499 | Notify: func(State, error) { reached <- struct{}{} }, | ||
| 1499 | }) | 1500 | }) | ||
| 1500 | ctx, cancel := context.WithCancel(context.Background()) | 1501 | ctx, cancel := context.WithCancel(context.Background()) | ||
A internal/usecase/colorprofile/profile.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | // Package colorprofile maps terminal color capabilities to the vev-vt renderer | |
| 2 | + | // color profile. It is the single mode-to-profile policy shared by the client | |
| 3 | + | // and the daemon. | |
| 4 | + | package colorprofile | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | ansi "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Profile returns the renderer profile for c. A terminal without RGB support | |
| 12 | + | // gets indexed colors, so RGB surfaces are quantized instead of dropped. | |
| 13 | + | func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile { | |
| 14 | + | if c.RGB() { | |
| 15 | + | return ansi.ColorProfileTrueColor | |
| 16 | + | } | |
| 17 | + | return ansi.ColorProfileANSI256 | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | // Package colorprofile maps terminal color capabilities to the vev-vt renderer | |||
| 2 | + | // color profile. It is the single mode-to-profile policy shared by the client | |||
| 3 | + | // and the daemon. | |||
| 4 | + | package colorprofile | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | ansi "github.com/bnema/vev-vt/ansi" | |||
| 8 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Profile returns the renderer profile for c. A terminal without RGB support | |||
| 12 | + | // gets indexed colors, so RGB surfaces are quantized instead of dropped. | |||
| 13 | + | func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile { | |||
| 14 | + | if c.RGB() { | |||
| 15 | + | return ansi.ColorProfileTrueColor | |||
| 16 | + | } | |||
| 17 | + | return ansi.ColorProfileANSI256 | |||
| 18 | + | } | |||
A internal/usecase/colorprofile/profile_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package colorprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | ansi "github.com/bnema/vev-vt/ansi" | |
| 7 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestProfile(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | in terminalcap.ColorCapabilities | |
| 15 | + | want ansi.ColorProfile | |
| 16 | + | }{ | |
| 17 | + | {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor}, | |
| 18 | + | {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor}, | |
| 19 | + | {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256}, | |
| 20 | + | } | |
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | require.Equal(t, tt.want, Profile(tt.in)) | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package colorprofile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | ansi "github.com/bnema/vev-vt/ansi" | |||
| 7 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestProfile(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | in terminalcap.ColorCapabilities | |||
| 15 | + | want ansi.ColorProfile | |||
| 16 | + | }{ | |||
| 17 | + | {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor}, | |||
| 18 | + | {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor}, | |||
| 19 | + | {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | require.Equal(t, tt.want, Profile(tt.in)) | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
M internal/usecase/daemon/attachment_output.go +2 -5
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 12 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // attachmentOutput owns the terminal-output dependency chain and emitted | |
| @@ -102,11 +103,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput { | |||
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | 105 | func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput { | |
| 105 | - | profile := renderer.ColorProfileANSI256 | |
| 106 | - | if capabilities.TrueColor() { | |
| 107 | - | profile = renderer.ColorProfileTrueColor | |
| 108 | - | } | |
| 109 | - | return newOutputStateStreamForProfile(profile, windowSize...) | |
| 106 | + | return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...) | |
| 110 | 107 | } | |
| 111 | 108 | ||
| 112 | 109 | func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/domain/terminalcap" | 11 | "github.com/bnema/vev/internal/domain/terminalcap" | ||
| 12 | "github.com/bnema/vev/internal/protocol" | 12 | "github.com/bnema/vev/internal/protocol" | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // attachmentOutput owns the terminal-output dependency chain and emitted | 16 | // attachmentOutput owns the terminal-output dependency chain and emitted | ||
| @@ -102,11 +103,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput { | |||||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 104 | func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput { | 105 | func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput { | ||
| 105 | - | profile := renderer.ColorProfileANSI256 | 106 | + | return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...) |
| 106 | - | if capabilities.TrueColor() { | |||
| 107 | - | profile = renderer.ColorProfileTrueColor | |||
| 108 | - | } | |||
| 109 | - | return newOutputStateStreamForProfile(profile, windowSize...) | |||
| 110 | } | 107 | } | ||
| 111 | 108 | ||||
| 112 | func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput { | 109 | func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput { | ||
M internal/usecase/daemon/attachment_output_test.go +2 -2
| @@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) { | |||
| 59 | 59 | }{ | |
| 60 | 60 | { | |
| 61 | 61 | name: "truecolor", | |
| 62 | - | capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}, | |
| 62 | + | capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, | |
| 63 | 63 | want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m", | |
| 64 | 64 | }, | |
| 65 | 65 | { | |
| 66 | 66 | name: "indexed 256", | |
| 67 | - | capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256}, | |
| 67 | + | capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |
| 68 | 68 | want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m", | |
| 69 | 69 | }, | |
| 70 | 70 | } | |
| @@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) { | |||||
| 59 | }{ | 59 | }{ | ||
| 60 | { | 60 | { | ||
| 61 | name: "truecolor", | 61 | name: "truecolor", | ||
| 62 | - | capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}, | 62 | + | capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, |
| 63 | want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m", | 63 | want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m", | ||
| 64 | }, | 64 | }, | ||
| 65 | { | 65 | { | ||
| 66 | name: "indexed 256", | 66 | name: "indexed 256", | ||
| 67 | - | capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256}, | 67 | + | capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, |
| 68 | want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m", | 68 | want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m", | ||
| 69 | }, | 69 | }, | ||
| 70 | } | 70 | } | ||
M internal/usecase/daemon/client.go +4 -2
| @@ -696,7 +696,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai | |||
| 696 | 696 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | |
| 697 | 697 | d.touchMRU(sess) | |
| 698 | 698 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | |
| 699 | - | if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() { | |
| 699 | + | if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() { | |
| 700 | 700 | d.publishToast(ac, domain.Notification{ | |
| 701 | 701 | Code: domain.NoticeUser, | |
| 702 | 702 | Severity: domain.NoticeWarn, | |
| @@ -721,7 +721,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 721 | 721 | resumeToken = d.nextResumeTokenLocked() | |
| 722 | 722 | } | |
| 723 | 723 | if !opts.capabilitiesSet { | |
| 724 | - | opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor} | |
| 724 | + | // The zero value is TrueColor: manually constructed attachments keep | |
| 725 | + | // the historical renderer behavior. | |
| 726 | + | opts.terminalCapabilities = terminalcap.Capabilities{} | |
| 725 | 727 | } | |
| 726 | 728 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | |
| 727 | 729 | geometry = geometry.NormalizePixels() | |
| @@ -696,7 +696,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai | |||||
| 696 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | 696 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | ||
| 697 | d.touchMRU(sess) | 697 | d.touchMRU(sess) | ||
| 698 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | 698 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | ||
| 699 | - | if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() { | 699 | + | if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() { |
| 700 | d.publishToast(ac, domain.Notification{ | 700 | d.publishToast(ac, domain.Notification{ | ||
| 701 | Code: domain.NoticeUser, | 701 | Code: domain.NoticeUser, | ||
| 702 | Severity: domain.NoticeWarn, | 702 | Severity: domain.NoticeWarn, | ||
| @@ -721,7 +721,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||||
| 721 | resumeToken = d.nextResumeTokenLocked() | 721 | resumeToken = d.nextResumeTokenLocked() | ||
| 722 | } | 722 | } | ||
| 723 | if !opts.capabilitiesSet { | 723 | if !opts.capabilitiesSet { | ||
| 724 | - | opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor} | 724 | + | // The zero value is TrueColor: manually constructed attachments keep |
| 725 | + | // the historical renderer behavior. | |||
| 726 | + | opts.terminalCapabilities = terminalcap.Capabilities{} | |||
| 725 | } | 727 | } | ||
| 726 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | 728 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | ||
| 727 | geometry = geometry.NormalizePixels() | 729 | geometry = geometry.NormalizePixels() | ||
M internal/usecase/daemon/daemon.go +1 -5
| @@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai | |||
| 1477 | 1477 | // otherwise falls back to the session's normal first-tab repair. | |
| 1478 | 1478 | initialTabIndex = preferredTabIndex(sess, h.PreferredTabID) | |
| 1479 | 1479 | } | |
| 1480 | - | terminalCapabilities := terminalcap.Detect(h.Env) | |
| 1480 | + | terminalCapabilities := terminalcap.Resolve(h.Env, h.TrueColor) | |
| 1481 | 1481 | // Kitty graphics are enabled only by the explicit direct-terminal | |
| 1482 | 1482 | // declaration in Hello. Environment values remain useful for color and | |
| 1483 | 1483 | // diagnostics, but cannot authorize terminal-global graphics side effects. | |
| 1484 | 1484 | terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | |
| 1485 | - | if h.TrueColor && !terminalCapabilities.TrueColor() { | |
| 1486 | - | terminalCapabilities.ColorMode = terminalcap.TrueColor | |
| 1487 | - | terminalCapabilities.ColorSource = terminalcap.SourceDeclared | |
| 1488 | - | } | |
| 1489 | 1485 | opts := attachClientOptions{ | |
| 1490 | 1486 | clientID: h.ClientID, | |
| 1491 | 1487 | kittyKeyboard: h.KittyKeyboard, | |
| @@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai | |||||
| 1477 | // otherwise falls back to the session's normal first-tab repair. | 1477 | // otherwise falls back to the session's normal first-tab repair. | ||
| 1478 | initialTabIndex = preferredTabIndex(sess, h.PreferredTabID) | 1478 | initialTabIndex = preferredTabIndex(sess, h.PreferredTabID) | ||
| 1479 | } | 1479 | } | ||
| 1480 | - | terminalCapabilities := terminalcap.Detect(h.Env) | 1480 | + | terminalCapabilities := terminalcap.Resolve(h.Env, h.TrueColor) |
| 1481 | // Kitty graphics are enabled only by the explicit direct-terminal | 1481 | // Kitty graphics are enabled only by the explicit direct-terminal | ||
| 1482 | // declaration in Hello. Environment values remain useful for color and | 1482 | // declaration in Hello. Environment values remain useful for color and | ||
| 1483 | // diagnostics, but cannot authorize terminal-global graphics side effects. | 1483 | // diagnostics, but cannot authorize terminal-global graphics side effects. | ||
| 1484 | terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | 1484 | terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | ||
| 1485 | - | if h.TrueColor && !terminalCapabilities.TrueColor() { | |||
| 1486 | - | terminalCapabilities.ColorMode = terminalcap.TrueColor | |||
| 1487 | - | terminalCapabilities.ColorSource = terminalcap.SourceDeclared | |||
| 1488 | - | } | |||
| 1489 | opts := attachClientOptions{ | 1485 | opts := attachClientOptions{ | ||
| 1490 | clientID: h.ClientID, | 1486 | clientID: h.ClientID, | ||
| 1491 | kittyKeyboard: h.KittyKeyboard, | 1487 | kittyKeyboard: h.KittyKeyboard, | ||
M internal/usecase/daemon/graphics_output_test.go +1 -1
| @@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) { | |||
| 510 | 510 | domain.Geometry{Size: defaultSize}, | |
| 511 | 511 | attachClientOptions{ | |
| 512 | 512 | capabilitiesSet: true, | |
| 513 | - | terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor}, | |
| 513 | + | terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, | |
| 514 | 514 | }, | |
| 515 | 515 | ) | |
| 516 | 516 | d.mu.Unlock() | |
| @@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) { | |||||
| 510 | domain.Geometry{Size: defaultSize}, | 510 | domain.Geometry{Size: defaultSize}, | ||
| 511 | attachClientOptions{ | 511 | attachClientOptions{ | ||
| 512 | capabilitiesSet: true, | 512 | capabilitiesSet: true, | ||
| 513 | - | terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor}, | 513 | + | terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, |
| 514 | }, | 514 | }, | ||
| 515 | ) | 515 | ) | ||
| 516 | d.mu.Unlock() | 516 | d.mu.Unlock() | ||