feat(terminal): support ANSI-16 and monochrome color profiles (#306)
* 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().
* feat(protocol): carry color capabilities in Hello
Replace Hello.TrueColor and Theme.TrueColor with Hello.color, a
ColorCapabilities{mode, source} message (field 26; Hello 12 and Theme 5
reserved). Add ANSI16 and Monochrome modes and the Forced source to
terminalcap, map all four modes in colorprofile.Profile, and make
terminalcap.Resolve take the declared capability: declared and forced claims
are used verbatim, anything else falls back to detection. The daemon
downgrade toast names the actual mode and is suppressed for forced modes.
sessionwire rejects a missing color, an UNSPECIFIED mode, or unknown enum
values. Bump protocol.Version to 65.
The usecase theme.Theme TrueColor field was transport-only and is dropped in
the same change.
* feat(terminal): detect ANSI-16 and monochrome terminals
Classify TERM values without a truecolor signal: dumb, vt52/100/102/220 and
-m/-mono names are monochrome; linux, ansi, cons25 and -16color/-color names
are 16 colors. Add terminalcap.ParseColorMode for explicit overrides.
* feat(config): add terminal.colors and VEV_COLORS overrides
Resolve an explicit color mode client-side (VEV_COLORS over terminal.colors,
auto means detection) and send it as a Forced Hello.Color; the picker gets the
same value. Web and headless UI-driver clients now declare forced truecolor
instead of inheriting the gateway process TERM.
* fix(daemon): word color downgrade notice per mode
* feat(theme): keep vev UI legible in ANSI-16 and monochrome
* fix(daemon): re-negotiate color profile when an attachment resumes
A resumed attachment kept the color mode and renderer profile from the
parked link, so a reconnect with a different Hello.Color encoded output
for the wrong terminal. Apply the latest Hello color, swap the renderer
profile, and re-resolve the chrome theme. Cover per-attachment profiles
across full, incremental, reset and resume paints.
* fix(client): mark picker problems without color in monochrome
Monochrome drops the fixed problem dot colors, leaving error, warning and
healthy rows indistinguishable. Draw error and warning dots bold and dim
the tree lines instead of relying on the grey foreground. Truecolor,
256-color and ANSI-16 bytes are unchanged.
* docs(terminal): document color modes, detection and overrides
* perf(theme): keep NewDimmer inlinable by reading DimByAttribute in Dim
* fix(daemon): publish color downgrade notice on resume and swap renderer only on mode change
* docs(terminal): clarify color override timing and heuristic claim resolution
* refactor(color): remove unused ColorCapabilities.Colors, pulseStyle wrapper and exported ReducedColor; document drawClientToast as info-only- commit
- a06b956a6dd506f4d85b4011c75b3c38fd0a1195
- author
- bnema · · 2026-10-03 14:19 UTC
- committer
- GitHub · · 2026-10-03 14:19 UTC
- parent
- 8a884f0
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
90 files · +2340 -338
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 buf.yaml +4 -0
| @@ -18,5 +18,9 @@ breaking: | |||
| 18 | 18 | ignore_only: | |
| 19 | 19 | # Protocol v58 intentionally replaces CommandResult.ok with a closed | |
| 20 | 20 | # outcome. Strict version equality prevents cross-version decoding. | |
| 21 | + | # Protocol v65 replaces the Hello and Theme true_color bools with | |
| 22 | + | # Hello.color capabilities under the same strict-equality rule. | |
| 21 | 23 | FIELD_NO_DELETE: | |
| 22 | 24 | - internal/protocol/wire/schema/command.proto | |
| 25 | + | - internal/protocol/wire/schema/session.proto | |
| 26 | + | - internal/protocol/wire/schema/terminal.proto | |
| @@ -18,5 +18,9 @@ breaking: | |||||
| 18 | ignore_only: | 18 | ignore_only: | ||
| 19 | # Protocol v58 intentionally replaces CommandResult.ok with a closed | 19 | # Protocol v58 intentionally replaces CommandResult.ok with a closed | ||
| 20 | # outcome. Strict version equality prevents cross-version decoding. | 20 | # outcome. Strict version equality prevents cross-version decoding. | ||
| 21 | + | # Protocol v65 replaces the Hello and Theme true_color bools with | |||
| 22 | + | # Hello.color capabilities under the same strict-equality rule. | |||
| 21 | FIELD_NO_DELETE: | 23 | FIELD_NO_DELETE: | ||
| 22 | - internal/protocol/wire/schema/command.proto | 24 | - internal/protocol/wire/schema/command.proto | ||
| 25 | + | - internal/protocol/wire/schema/session.proto | |||
| 26 | + | - internal/protocol/wire/schema/terminal.proto | |||
M docs/architecture.md +3 -2
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Architecture | |
| 2 | 2 | ||
| 3 | - | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 59. | |
| 3 | + | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 65. | |
| 4 | 4 | ||
| 5 | 5 | `CommandResult` has a closed explicit outcome contract: | |
| 6 | 6 | ||
| @@ -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 | |
| @@ -131,7 +132,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 131 | 132 | `go tool buf generate`, and handle the new variant in `sessionwire`. | |
| 132 | 133 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | |
| 133 | 134 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 134 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 135 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `65`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 135 | 136 | ||
| 136 | 137 | ## Broker | |
| 137 | 138 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Architecture | 1 | # Architecture | ||
| 2 | 2 | ||||
| 3 | - | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 59. | 3 | + | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 65. |
| 4 | 4 | ||||
| 5 | `CommandResult` has a closed explicit outcome contract: | 5 | `CommandResult` has a closed explicit outcome contract: | ||
| 6 | 6 | ||||
| @@ -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 | ||
| @@ -131,7 +132,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||||
| 131 | `go tool buf generate`, and handle the new variant in `sessionwire`. | 132 | `go tool buf generate`, and handle the new variant in `sessionwire`. | ||
| 132 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | 133 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | ||
| 133 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | 134 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | ||
| 134 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | 135 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `65`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. |
| 135 | 136 | ||||
| 136 | ## Broker | 137 | ## Broker | ||
| 137 | 138 | ||||
M docs/configuration.md +5 -1
| @@ -43,6 +43,9 @@ ephemeral.close-on-exit = on | |||
| 43 | 43 | # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9). | |
| 44 | 44 | keyboard.kitty-protocol = on | |
| 45 | 45 | ||
| 46 | + | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | |
| 47 | + | terminal.colors = auto | |
| 48 | + | ||
| 46 | 49 | # Rebindable actions. Leave a line out to keep its built-in binding. | |
| 47 | 50 | open-palette = alt+space | |
| 48 | 51 | toggle-floating-pane = alt+f | |
| @@ -207,7 +210,8 @@ If the daemon refuses to start because its session data is broken, do not edit t | |||
| 207 | 210 | | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. | | |
| 208 | 211 | ||
| 209 | 212 | - vev only colors its own UI (bars, borders, palette). Pane content is never recolored. | |
| 210 | - | - Tinted backgrounds need a truecolor terminal. Otherwise the accent only colors text and borders. | |
| 213 | + | - Tinted backgrounds need a truecolor or 256-color terminal. On 16-color and monochrome terminals vev uses bold, reverse, and faint instead. See [terminal colors](terminal.md#colors). | |
| 214 | + | - `terminal.colors` forces the color mode of your terminal (`auto`, `truecolor`, `256`, `16`, `mono`). `VEV_COLORS` overrides it. See [terminal colors](terminal.md#colors). | |
| 211 | 215 | - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report. | |
| 212 | 216 | ||
| 213 | 217 | ## Bindings | |
| @@ -43,6 +43,9 @@ ephemeral.close-on-exit = on | |||||
| 43 | # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9). | 43 | # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9). | ||
| 44 | keyboard.kitty-protocol = on | 44 | keyboard.kitty-protocol = on | ||
| 45 | 45 | ||||
| 46 | + | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | |||
| 47 | + | terminal.colors = auto | |||
| 48 | + | ||||
| 46 | # Rebindable actions. Leave a line out to keep its built-in binding. | 49 | # Rebindable actions. Leave a line out to keep its built-in binding. | ||
| 47 | open-palette = alt+space | 50 | open-palette = alt+space | ||
| 48 | toggle-floating-pane = alt+f | 51 | toggle-floating-pane = alt+f | ||
| @@ -207,7 +210,8 @@ If the daemon refuses to start because its session data is broken, do not edit t | |||||
| 207 | | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. | | 210 | | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. | | ||
| 208 | 211 | ||||
| 209 | - vev only colors its own UI (bars, borders, palette). Pane content is never recolored. | 212 | - vev only colors its own UI (bars, borders, palette). Pane content is never recolored. | ||
| 210 | - | - Tinted backgrounds need a truecolor terminal. Otherwise the accent only colors text and borders. | 213 | + | - Tinted backgrounds need a truecolor or 256-color terminal. On 16-color and monochrome terminals vev uses bold, reverse, and faint instead. See [terminal colors](terminal.md#colors). |
| 214 | + | - `terminal.colors` forces the color mode of your terminal (`auto`, `truecolor`, `256`, `16`, `mono`). `VEV_COLORS` overrides it. See [terminal colors](terminal.md#colors). | |||
| 211 | - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report. | 215 | - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report. | ||
| 212 | 216 | ||||
| 213 | ## Bindings | 217 | ## Bindings | ||
M docs/protocol.md +12 -0
| @@ -88,6 +88,18 @@ constant or the request fails | |||
| 88 | 88 | with the version-mismatch response above. There is no multi-version | |
| 89 | 89 | negotiation: equality is mandatory. | |
| 90 | 90 | ||
| 91 | + | `Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier | |
| 92 | + | of terminal color capability; `Theme` has no capability field. `mode` is a | |
| 93 | + | closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing | |
| 94 | + | message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello | |
| 95 | + | (`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means | |
| 96 | + | "unknown": the daemon then ignores the claimed mode and re-detects from | |
| 97 | + | `Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim. A `HEURISTIC` truecolor claim | |
| 98 | + | upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back | |
| 99 | + | to detection. | |
| 100 | + | (`internal/domain/terminalcap.Resolve`; wire tests in | |
| 101 | + | `sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.) | |
| 102 | + | ||
| 91 | 103 | Command requests additionally carry a 10-second result deadline | |
| 92 | 104 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | |
| 93 | 105 | connection and correlated by `RequestID`. Every dispatched request produces | |
| @@ -88,6 +88,18 @@ constant or the request fails | |||||
| 88 | with the version-mismatch response above. There is no multi-version | 88 | with the version-mismatch response above. There is no multi-version | ||
| 89 | negotiation: equality is mandatory. | 89 | negotiation: equality is mandatory. | ||
| 90 | 90 | ||||
| 91 | + | `Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier | |||
| 92 | + | of terminal color capability; `Theme` has no capability field. `mode` is a | |||
| 93 | + | closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing | |||
| 94 | + | message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello | |||
| 95 | + | (`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means | |||
| 96 | + | "unknown": the daemon then ignores the claimed mode and re-detects from | |||
| 97 | + | `Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim. A `HEURISTIC` truecolor claim | |||
| 98 | + | upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back | |||
| 99 | + | to detection. | |||
| 100 | + | (`internal/domain/terminalcap.Resolve`; wire tests in | |||
| 101 | + | `sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.) | |||
| 102 | + | ||||
| 91 | Command requests additionally carry a 10-second result deadline | 103 | Command requests additionally carry a 10-second result deadline | ||
| 92 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | 104 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | ||
| 93 | connection and correlated by `RequestID`. Every dispatched request produces | 105 | connection and correlated by `RequestID`. Every dispatched request produces | ||
M docs/terminal.md +49 -1
| @@ -13,7 +13,55 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`. | |||
| 13 | 13 | ||
| 14 | 14 | - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`. | |
| 15 | 15 | - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors. | |
| 16 | - | - If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning. | |
| 16 | + | ||
| 17 | + | ### Outer terminal color modes | |
| 18 | + | ||
| 19 | + | vev picks a color mode for each attachment, so several clients on one session can use different modes. Modes: `truecolor`, `256`, `16`, `monochrome`. | |
| 20 | + | ||
| 21 | + | Detection reads the client's `TERM` and `COLORTERM`. The first matching row wins: | |
| 22 | + | ||
| 23 | + | | Order | Signal | Mode | | |
| 24 | + | |---|---|---| | |
| 25 | + | | 1 | `COLORTERM` is `truecolor` or `24bit`, or `TERM` is `xterm-direct` or ends in `-direct` | truecolor | | |
| 26 | + | | 2 | `TERM=xterm-kitty` inside kitty | truecolor | | |
| 27 | + | | 3 | `TERM` is `dumb`, `vt52`, `vt100`, `vt102`, `vt220`, or ends in `-m` or `-mono` | monochrome | | |
| 28 | + | | 4 | `TERM` contains `256color` | 256 | | |
| 29 | + | | 5 | `TERM` is `linux`, `ansi`, `cons25`, or ends in `-16color` or `-color` | 16 (8-color terminals use the first 8) | | |
| 30 | + | | 6 | anything else | 256 (unverified guess) | | |
| 31 | + | ||
| 32 | + | When detection finds a terminal with fewer than truecolor colors, vev shows a one-time notice. The notice is not shown for the unverified guess or for a forced mode. | |
| 33 | + | ||
| 34 | + | ### Override the mode | |
| 35 | + | ||
| 36 | + | | Where | Example | Notes | | |
| 37 | + | |---|---|---| | |
| 38 | + | | `VEV_COLORS` environment variable | `VEV_COLORS=16 vev` | Wins over the config key. An invalid value is ignored and logged as a warning. | | |
| 39 | + | | `terminal.colors` in the [configuration](configuration.md) | `terminal.colors = mono` | Applies to every client that has no `VEV_COLORS`. | | |
| 40 | + | ||
| 41 | + | Values: `auto`, `truecolor`, `256`, `16`, `mono`. `auto` (or an empty value) means detection. A forced mode never shows the notice. | |
| 42 | + | ||
| 43 | + | `VEV_COLORS` and `terminal.colors` are read when the client starts; restart the client to apply a change. A config reload in the running daemon does not change attached clients. | |
| 44 | + | ||
| 45 | + | ### What each mode changes | |
| 46 | + | ||
| 47 | + | Pane content: | |
| 48 | + | ||
| 49 | + | | Mode | Pane colors | | |
| 50 | + | |---|---| | |
| 51 | + | | truecolor | Unchanged. | | |
| 52 | + | | 256 | RGB colors map to the xterm 256-color palette. | | |
| 53 | + | | 16 | RGB and indexed colors 16-255 map to the nearest of the 16 ANSI colors. Underline color is dropped. | | |
| 54 | + | | monochrome | All colors are dropped. Attributes (bold, underline, reverse, and so on) stay. | | |
| 55 | + | ||
| 56 | + | vev's own UI (bars, borders, pickers, overlays): | |
| 57 | + | ||
| 58 | + | | Mode | UI | | |
| 59 | + | |---|---| | |
| 60 | + | | truecolor, 256 | Full theme. | | |
| 61 | + | | 16 | No tinted backgrounds. Active and selected items use bold and reverse, secondary text uses faint. The accent is a terminal palette slot. | | |
| 62 | + | | monochrome | Same as 16 with no color at all. | | |
| 63 | + | ||
| 64 | + | The browser terminal and the headless `--ui-driver` always use truecolor, whatever `TERM`, `COLORTERM`, `VEV_COLORS`, or `terminal.colors` say. | |
| 17 | 65 | ||
| 18 | 66 | ### Install `xterm-direct` | |
| 19 | 67 | ||
| @@ -13,7 +13,55 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`. | |||||
| 13 | 13 | ||||
| 14 | - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`. | 14 | - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`. | ||
| 15 | - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors. | 15 | - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors. | ||
| 16 | - | - If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning. | 16 | + | |
| 17 | + | ### Outer terminal color modes | |||
| 18 | + | ||||
| 19 | + | vev picks a color mode for each attachment, so several clients on one session can use different modes. Modes: `truecolor`, `256`, `16`, `monochrome`. | |||
| 20 | + | ||||
| 21 | + | Detection reads the client's `TERM` and `COLORTERM`. The first matching row wins: | |||
| 22 | + | ||||
| 23 | + | | Order | Signal | Mode | | |||
| 24 | + | |---|---|---| | |||
| 25 | + | | 1 | `COLORTERM` is `truecolor` or `24bit`, or `TERM` is `xterm-direct` or ends in `-direct` | truecolor | | |||
| 26 | + | | 2 | `TERM=xterm-kitty` inside kitty | truecolor | | |||
| 27 | + | | 3 | `TERM` is `dumb`, `vt52`, `vt100`, `vt102`, `vt220`, or ends in `-m` or `-mono` | monochrome | | |||
| 28 | + | | 4 | `TERM` contains `256color` | 256 | | |||
| 29 | + | | 5 | `TERM` is `linux`, `ansi`, `cons25`, or ends in `-16color` or `-color` | 16 (8-color terminals use the first 8) | | |||
| 30 | + | | 6 | anything else | 256 (unverified guess) | | |||
| 31 | + | ||||
| 32 | + | When detection finds a terminal with fewer than truecolor colors, vev shows a one-time notice. The notice is not shown for the unverified guess or for a forced mode. | |||
| 33 | + | ||||
| 34 | + | ### Override the mode | |||
| 35 | + | ||||
| 36 | + | | Where | Example | Notes | | |||
| 37 | + | |---|---|---| | |||
| 38 | + | | `VEV_COLORS` environment variable | `VEV_COLORS=16 vev` | Wins over the config key. An invalid value is ignored and logged as a warning. | | |||
| 39 | + | | `terminal.colors` in the [configuration](configuration.md) | `terminal.colors = mono` | Applies to every client that has no `VEV_COLORS`. | | |||
| 40 | + | ||||
| 41 | + | Values: `auto`, `truecolor`, `256`, `16`, `mono`. `auto` (or an empty value) means detection. A forced mode never shows the notice. | |||
| 42 | + | ||||
| 43 | + | `VEV_COLORS` and `terminal.colors` are read when the client starts; restart the client to apply a change. A config reload in the running daemon does not change attached clients. | |||
| 44 | + | ||||
| 45 | + | ### What each mode changes | |||
| 46 | + | ||||
| 47 | + | Pane content: | |||
| 48 | + | ||||
| 49 | + | | Mode | Pane colors | | |||
| 50 | + | |---|---| | |||
| 51 | + | | truecolor | Unchanged. | | |||
| 52 | + | | 256 | RGB colors map to the xterm 256-color palette. | | |||
| 53 | + | | 16 | RGB and indexed colors 16-255 map to the nearest of the 16 ANSI colors. Underline color is dropped. | | |||
| 54 | + | | monochrome | All colors are dropped. Attributes (bold, underline, reverse, and so on) stay. | | |||
| 55 | + | ||||
| 56 | + | vev's own UI (bars, borders, pickers, overlays): | |||
| 57 | + | ||||
| 58 | + | | Mode | UI | | |||
| 59 | + | |---|---| | |||
| 60 | + | | truecolor, 256 | Full theme. | | |||
| 61 | + | | 16 | No tinted backgrounds. Active and selected items use bold and reverse, secondary text uses faint. The accent is a terminal palette slot. | | |||
| 62 | + | | monochrome | Same as 16 with no color at all. | | |||
| 63 | + | ||||
| 64 | + | The browser terminal and the headless `--ui-driver` always use truecolor, whatever `TERM`, `COLORTERM`, `VEV_COLORS`, or `terminal.colors` say. | |||
| 17 | 65 | ||||
| 18 | ### Install `xterm-direct` | 66 | ### Install `xterm-direct` | ||
| 19 | 67 | ||||
M internal/adapters/config/config.go +10 -0
| @@ -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 | ) | |
| 18 | 19 | ||
| @@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 37 | 38 | seenTabsKeys := make(map[string]bool) | |
| 38 | 39 | seenEphemeralKeys := make(map[string]bool) | |
| 39 | 40 | seenKeyboardKeys := make(map[string]bool) | |
| 41 | + | seenTerminalKeys := make(map[string]bool) | |
| 40 | 42 | seenScrollbackKeys := make(map[string]bool) | |
| 41 | 43 | seenWebKeys := make(map[string]bool) | |
| 42 | 44 | ||
| @@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 171 | 173 | continue | |
| 172 | 174 | } | |
| 173 | 175 | cfg.Keyboard.KittyProtocol = on | |
| 176 | + | case key == "terminal.colors": | |
| 177 | + | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | |
| 178 | + | mode, auto, err := terminalcap.ParseColorMode(value) | |
| 179 | + | if err != nil { | |
| 180 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)}) | |
| 181 | + | continue | |
| 182 | + | } | |
| 183 | + | cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto} | |
| 174 | 184 | case key == "copy.word-separators": | |
| 175 | 185 | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 176 | 186 | separators, ok := parseConfigString(value) | |
| @@ -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 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 37 | seenTabsKeys := make(map[string]bool) | 38 | seenTabsKeys := make(map[string]bool) | ||
| 38 | seenEphemeralKeys := make(map[string]bool) | 39 | seenEphemeralKeys := make(map[string]bool) | ||
| 39 | seenKeyboardKeys := make(map[string]bool) | 40 | seenKeyboardKeys := make(map[string]bool) | ||
| 41 | + | seenTerminalKeys := make(map[string]bool) | |||
| 40 | seenScrollbackKeys := make(map[string]bool) | 42 | seenScrollbackKeys := make(map[string]bool) | ||
| 41 | seenWebKeys := make(map[string]bool) | 43 | seenWebKeys := make(map[string]bool) | ||
| 42 | 44 | ||||
| @@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 171 | continue | 173 | continue | ||
| 172 | } | 174 | } | ||
| 173 | cfg.Keyboard.KittyProtocol = on | 175 | cfg.Keyboard.KittyProtocol = on | ||
| 176 | + | case key == "terminal.colors": | |||
| 177 | + | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | |||
| 178 | + | mode, auto, err := terminalcap.ParseColorMode(value) | |||
| 179 | + | if err != nil { | |||
| 180 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)}) | |||
| 181 | + | continue | |||
| 182 | + | } | |||
| 183 | + | cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto} | |||
| 174 | case key == "copy.word-separators": | 184 | case key == "copy.word-separators": | ||
| 175 | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | 185 | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | ||
| 176 | separators, ok := parseConfigString(value) | 186 | separators, ok := parseConfigString(value) | ||
A internal/adapters/config/terminal_colors_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestParseTerminalColors(t *testing.T) { | |
| 14 | + | for _, tt := range []struct { | |
| 15 | + | name string | |
| 16 | + | input string | |
| 17 | + | want domain.TerminalConfig | |
| 18 | + | warnings int | |
| 19 | + | }{ | |
| 20 | + | {"default is auto", "", domain.TerminalConfig{}, 0}, | |
| 21 | + | {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0}, | |
| 22 | + | {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0}, | |
| 23 | + | {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0}, | |
| 24 | + | {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0}, | |
| 25 | + | {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0}, | |
| 26 | + | {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0}, | |
| 27 | + | {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0}, | |
| 28 | + | {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0}, | |
| 29 | + | {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1}, | |
| 30 | + | {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2}, | |
| 31 | + | {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1}, | |
| 32 | + | {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1}, | |
| 33 | + | } { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Equal(t, tt.want, cfg.Terminal) | |
| 38 | + | require.Len(t, warnings, tt.warnings) | |
| 39 | + | require.Empty(t, cfg.BindingEntries) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestParseTerminalColors(t *testing.T) { | |||
| 14 | + | for _, tt := range []struct { | |||
| 15 | + | name string | |||
| 16 | + | input string | |||
| 17 | + | want domain.TerminalConfig | |||
| 18 | + | warnings int | |||
| 19 | + | }{ | |||
| 20 | + | {"default is auto", "", domain.TerminalConfig{}, 0}, | |||
| 21 | + | {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0}, | |||
| 22 | + | {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0}, | |||
| 23 | + | {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0}, | |||
| 24 | + | {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0}, | |||
| 25 | + | {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0}, | |||
| 26 | + | {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0}, | |||
| 27 | + | {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0}, | |||
| 28 | + | {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0}, | |||
| 29 | + | {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1}, | |||
| 30 | + | {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2}, | |||
| 31 | + | {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1}, | |||
| 32 | + | {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1}, | |||
| 33 | + | } { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Equal(t, tt.want, cfg.Terminal) | |||
| 38 | + | require.Len(t, warnings, tt.warnings) | |||
| 39 | + | require.Empty(t, cfg.BindingEntries) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M internal/adapters/sessionwire/client_test.go +1 -1
| @@ -26,7 +26,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 26 | 26 | {name: "ping", message: protocol.Ping{}}, | |
| 27 | 27 | {name: "list", message: protocol.List{}}, | |
| 28 | 28 | {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}}, | |
| 29 | - | {name: "theme", message: protocol.Theme{TrueColor: true}}, | |
| 29 | + | {name: "theme", message: protocol.Theme{SchemeKnown: true}}, | |
| 30 | 30 | {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}}, | |
| 31 | 31 | {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}}, | |
| 32 | 32 | {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | |
| @@ -26,7 +26,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||||
| 26 | {name: "ping", message: protocol.Ping{}}, | 26 | {name: "ping", message: protocol.Ping{}}, | ||
| 27 | {name: "list", message: protocol.List{}}, | 27 | {name: "list", message: protocol.List{}}, | ||
| 28 | {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}}, | 28 | {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}}, | ||
| 29 | - | {name: "theme", message: protocol.Theme{TrueColor: true}}, | 29 | + | {name: "theme", message: protocol.Theme{SchemeKnown: true}}, |
| 30 | {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}}, | 30 | {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}}, | ||
| 31 | {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}}, | 31 | {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}}, | ||
| 32 | {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | 32 | {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | ||
M internal/adapters/sessionwire/proto_convert_misc.go +0 -2
| @@ -69,7 +69,6 @@ func themeToWire(message protocol.Theme) *wire.Theme { | |||
| 69 | 69 | Foreground: rgbToWire(message.Foreground), | |
| 70 | 70 | HasBackground: message.HasBackground, | |
| 71 | 71 | Background: rgbToWire(message.Background), | |
| 72 | - | TrueColor: message.TrueColor, | |
| 73 | 72 | SchemeKnown: message.SchemeKnown, | |
| 74 | 73 | Light: message.Light, | |
| 75 | 74 | PaletteKnown: uint32(message.PaletteKnown), | |
| @@ -97,7 +96,6 @@ func themeFromWire(message *wire.Theme) (protocol.Theme, error) { | |||
| 97 | 96 | theme.Foreground = foreground | |
| 98 | 97 | theme.HasBackground = message.GetHasBackground() | |
| 99 | 98 | theme.Background = background | |
| 100 | - | theme.TrueColor = message.GetTrueColor() | |
| 101 | 99 | theme.SchemeKnown = message.GetSchemeKnown() | |
| 102 | 100 | theme.Light = message.GetLight() | |
| 103 | 101 | known := message.GetPaletteKnown() | |
| @@ -69,7 +69,6 @@ func themeToWire(message protocol.Theme) *wire.Theme { | |||||
| 69 | Foreground: rgbToWire(message.Foreground), | 69 | Foreground: rgbToWire(message.Foreground), | ||
| 70 | HasBackground: message.HasBackground, | 70 | HasBackground: message.HasBackground, | ||
| 71 | Background: rgbToWire(message.Background), | 71 | Background: rgbToWire(message.Background), | ||
| 72 | - | TrueColor: message.TrueColor, | |||
| 73 | SchemeKnown: message.SchemeKnown, | 72 | SchemeKnown: message.SchemeKnown, | ||
| 74 | Light: message.Light, | 73 | Light: message.Light, | ||
| 75 | PaletteKnown: uint32(message.PaletteKnown), | 74 | PaletteKnown: uint32(message.PaletteKnown), | ||
| @@ -97,7 +96,6 @@ func themeFromWire(message *wire.Theme) (protocol.Theme, error) { | |||||
| 97 | theme.Foreground = foreground | 96 | theme.Foreground = foreground | ||
| 98 | theme.HasBackground = message.GetHasBackground() | 97 | theme.HasBackground = message.GetHasBackground() | ||
| 99 | theme.Background = background | 98 | theme.Background = background | ||
| 100 | - | theme.TrueColor = message.GetTrueColor() | |||
| 101 | theme.SchemeKnown = message.GetSchemeKnown() | 99 | theme.SchemeKnown = message.GetSchemeKnown() | ||
| 102 | theme.Light = message.GetLight() | 100 | theme.Light = message.GetLight() | ||
| 103 | known := message.GetPaletteKnown() | 101 | known := message.GetPaletteKnown() | ||
M internal/adapters/sessionwire/proto_convert_range_test.go +57 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 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/protocol" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -80,6 +81,62 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) { | |||
| 80 | 81 | }, | |
| 81 | 82 | wantErr: protocol.ErrInvalidHello, | |
| 82 | 83 | }, | |
| 84 | + | { | |
| 85 | + | name: "hello color missing", | |
| 86 | + | run: func(t *testing.T) error { | |
| 87 | + | hello := testWireHello(t) | |
| 88 | + | hello.Color = nil | |
| 89 | + | _, err := helloFromWire(hello) | |
| 90 | + | return err | |
| 91 | + | }, | |
| 92 | + | wantErr: errProtoConvertRange, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "hello color mode unspecified", | |
| 96 | + | run: func(t *testing.T) error { | |
| 97 | + | hello := testWireHello(t) | |
| 98 | + | hello.Color.Mode = wire.ColorMode_COLOR_MODE_UNSPECIFIED | |
| 99 | + | _, err := helloFromWire(hello) | |
| 100 | + | return err | |
| 101 | + | }, | |
| 102 | + | wantErr: errProtoConvertRange, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "hello color mode unknown", | |
| 106 | + | run: func(t *testing.T) error { | |
| 107 | + | hello := testWireHello(t) | |
| 108 | + | hello.Color.Mode = wire.ColorMode(5) | |
| 109 | + | _, err := helloFromWire(hello) | |
| 110 | + | return err | |
| 111 | + | }, | |
| 112 | + | wantErr: errProtoConvertRange, | |
| 113 | + | }, | |
| 114 | + | { | |
| 115 | + | name: "hello color source unknown", | |
| 116 | + | run: func(t *testing.T) error { | |
| 117 | + | hello := testWireHello(t) | |
| 118 | + | hello.Color.Source = wire.ColorSource(4) | |
| 119 | + | _, err := helloFromWire(hello) | |
| 120 | + | return err | |
| 121 | + | }, | |
| 122 | + | wantErr: errProtoConvertRange, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "hello color mode not encodable", | |
| 126 | + | run: func(t *testing.T) error { | |
| 127 | + | _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}}) | |
| 128 | + | return err | |
| 129 | + | }, | |
| 130 | + | wantErr: errProtoConvertRange, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "hello color source not encodable", | |
| 134 | + | run: func(t *testing.T) error { | |
| 135 | + | _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Source: terminalcap.Source(9)}}) | |
| 136 | + | return err | |
| 137 | + | }, | |
| 138 | + | wantErr: errProtoConvertRange, | |
| 139 | + | }, | |
| 83 | 140 | { | |
| 84 | 141 | name: "hello geometry", | |
| 85 | 142 | run: func(t *testing.T) error { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 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/protocol" | 8 | "github.com/bnema/vev/internal/protocol" | ||
| 8 | "github.com/bnema/vev/internal/protocol/wire" | 9 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -80,6 +81,62 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) { | |||||
| 80 | }, | 81 | }, | ||
| 81 | wantErr: protocol.ErrInvalidHello, | 82 | wantErr: protocol.ErrInvalidHello, | ||
| 82 | }, | 83 | }, | ||
| 84 | + | { | |||
| 85 | + | name: "hello color missing", | |||
| 86 | + | run: func(t *testing.T) error { | |||
| 87 | + | hello := testWireHello(t) | |||
| 88 | + | hello.Color = nil | |||
| 89 | + | _, err := helloFromWire(hello) | |||
| 90 | + | return err | |||
| 91 | + | }, | |||
| 92 | + | wantErr: errProtoConvertRange, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "hello color mode unspecified", | |||
| 96 | + | run: func(t *testing.T) error { | |||
| 97 | + | hello := testWireHello(t) | |||
| 98 | + | hello.Color.Mode = wire.ColorMode_COLOR_MODE_UNSPECIFIED | |||
| 99 | + | _, err := helloFromWire(hello) | |||
| 100 | + | return err | |||
| 101 | + | }, | |||
| 102 | + | wantErr: errProtoConvertRange, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "hello color mode unknown", | |||
| 106 | + | run: func(t *testing.T) error { | |||
| 107 | + | hello := testWireHello(t) | |||
| 108 | + | hello.Color.Mode = wire.ColorMode(5) | |||
| 109 | + | _, err := helloFromWire(hello) | |||
| 110 | + | return err | |||
| 111 | + | }, | |||
| 112 | + | wantErr: errProtoConvertRange, | |||
| 113 | + | }, | |||
| 114 | + | { | |||
| 115 | + | name: "hello color source unknown", | |||
| 116 | + | run: func(t *testing.T) error { | |||
| 117 | + | hello := testWireHello(t) | |||
| 118 | + | hello.Color.Source = wire.ColorSource(4) | |||
| 119 | + | _, err := helloFromWire(hello) | |||
| 120 | + | return err | |||
| 121 | + | }, | |||
| 122 | + | wantErr: errProtoConvertRange, | |||
| 123 | + | }, | |||
| 124 | + | { | |||
| 125 | + | name: "hello color mode not encodable", | |||
| 126 | + | run: func(t *testing.T) error { | |||
| 127 | + | _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}}) | |||
| 128 | + | return err | |||
| 129 | + | }, | |||
| 130 | + | wantErr: errProtoConvertRange, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "hello color source not encodable", | |||
| 134 | + | run: func(t *testing.T) error { | |||
| 135 | + | _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Source: terminalcap.Source(9)}}) | |||
| 136 | + | return err | |||
| 137 | + | }, | |||
| 138 | + | wantErr: errProtoConvertRange, | |||
| 139 | + | }, | |||
| 83 | { | 140 | { | ||
| 84 | name: "hello geometry", | 141 | name: "hello geometry", | ||
| 85 | run: func(t *testing.T) error { | 142 | run: func(t *testing.T) error { | ||
M internal/adapters/sessionwire/proto_convert_session.go +80 -2
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "google.golang.org/protobuf/proto" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | 17 | ) | |
| @@ -29,6 +30,10 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 29 | 30 | return nil, err | |
| 30 | 31 | } | |
| 31 | 32 | exact := exactTargetToWire(message.ExactTarget) | |
| 33 | + | color, err := colorToWire(message.Color) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, err | |
| 36 | + | } | |
| 32 | 37 | return &wire.Hello{ | |
| 33 | 38 | Version: uint32(message.Version), | |
| 34 | 39 | Intent: uint32(message.Intent), | |
| @@ -41,7 +46,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 41 | 46 | PixelHeight: pixelHeight, | |
| 42 | 47 | TermEnv: message.TermEnv, | |
| 43 | 48 | Cwd: message.Cwd, | |
| 44 | - | TrueColor: message.TrueColor, | |
| 49 | + | Color: color, | |
| 45 | 50 | MaxOutputInFlight: uint32(message.MaxOutputInFlight), | |
| 46 | 51 | Env: append([]string(nil), message.Env...), | |
| 47 | 52 | SessionTarget: sessionTarget, | |
| @@ -57,6 +62,76 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 57 | 62 | }, nil | |
| 58 | 63 | } | |
| 59 | 64 | ||
| 65 | + | // colorToWire maps a color capability to its wire message. Unknown semantic | |
| 66 | + | // values are never encoded. | |
| 67 | + | func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) { | |
| 68 | + | if !color.Valid() { | |
| 69 | + | return nil, errProtoConvertRange | |
| 70 | + | } | |
| 71 | + | var mode wire.ColorMode | |
| 72 | + | switch color.Mode { | |
| 73 | + | case terminalcap.TrueColor: | |
| 74 | + | mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR | |
| 75 | + | case terminalcap.ANSI256: | |
| 76 | + | mode = wire.ColorMode_COLOR_MODE_ANSI256 | |
| 77 | + | case terminalcap.ANSI16: | |
| 78 | + | mode = wire.ColorMode_COLOR_MODE_ANSI16 | |
| 79 | + | case terminalcap.Monochrome: | |
| 80 | + | mode = wire.ColorMode_COLOR_MODE_MONOCHROME | |
| 81 | + | default: | |
| 82 | + | return nil, errProtoConvertRange | |
| 83 | + | } | |
| 84 | + | var source wire.ColorSource | |
| 85 | + | switch color.Source { | |
| 86 | + | case terminalcap.SourceUnknown: | |
| 87 | + | source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED | |
| 88 | + | case terminalcap.SourceHeuristic: | |
| 89 | + | source = wire.ColorSource_COLOR_SOURCE_HEURISTIC | |
| 90 | + | case terminalcap.SourceDeclared: | |
| 91 | + | source = wire.ColorSource_COLOR_SOURCE_DECLARED | |
| 92 | + | case terminalcap.SourceForced: | |
| 93 | + | source = wire.ColorSource_COLOR_SOURCE_FORCED | |
| 94 | + | default: | |
| 95 | + | return nil, errProtoConvertRange | |
| 96 | + | } | |
| 97 | + | return &wire.ColorCapabilities{Mode: mode, Source: source}, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // colorFromWire is strict: Hello must claim a color capability, so a missing | |
| 101 | + | // message, an UNSPECIFIED mode, or any value outside the closed enums is a | |
| 102 | + | // decode error. An UNSPECIFIED source is the explicit "unknown" source. | |
| 103 | + | func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) { | |
| 104 | + | if message == nil { | |
| 105 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |
| 106 | + | } | |
| 107 | + | var color terminalcap.ColorCapabilities | |
| 108 | + | switch message.GetMode() { | |
| 109 | + | case wire.ColorMode_COLOR_MODE_TRUE_COLOR: | |
| 110 | + | color.Mode = terminalcap.TrueColor | |
| 111 | + | case wire.ColorMode_COLOR_MODE_ANSI256: | |
| 112 | + | color.Mode = terminalcap.ANSI256 | |
| 113 | + | case wire.ColorMode_COLOR_MODE_ANSI16: | |
| 114 | + | color.Mode = terminalcap.ANSI16 | |
| 115 | + | case wire.ColorMode_COLOR_MODE_MONOCHROME: | |
| 116 | + | color.Mode = terminalcap.Monochrome | |
| 117 | + | default: | |
| 118 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |
| 119 | + | } | |
| 120 | + | switch message.GetSource() { | |
| 121 | + | case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED: | |
| 122 | + | color.Source = terminalcap.SourceUnknown | |
| 123 | + | case wire.ColorSource_COLOR_SOURCE_HEURISTIC: | |
| 124 | + | color.Source = terminalcap.SourceHeuristic | |
| 125 | + | case wire.ColorSource_COLOR_SOURCE_DECLARED: | |
| 126 | + | color.Source = terminalcap.SourceDeclared | |
| 127 | + | case wire.ColorSource_COLOR_SOURCE_FORCED: | |
| 128 | + | color.Source = terminalcap.SourceForced | |
| 129 | + | default: | |
| 130 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |
| 131 | + | } | |
| 132 | + | return color, nil | |
| 133 | + | } | |
| 134 | + | ||
| 60 | 135 | func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) { | |
| 61 | 136 | for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} { | |
| 62 | 137 | if value < 0 || value > math.MaxUint16 { | |
| @@ -108,7 +183,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||
| 108 | 183 | hello.PixelHeight = int(pixelHeight) | |
| 109 | 184 | hello.TermEnv = message.GetTermEnv() | |
| 110 | 185 | hello.Cwd = message.GetCwd() | |
| 111 | - | hello.TrueColor = message.GetTrueColor() | |
| 186 | + | hello.Color, err = colorFromWire(message.GetColor()) | |
| 187 | + | if err != nil { | |
| 188 | + | return protocol.Hello{}, err | |
| 189 | + | } | |
| 112 | 190 | window, err := mustUint8(message.GetMaxOutputInFlight()) | |
| 113 | 191 | if err != nil { | |
| 114 | 192 | return protocol.Hello{}, err | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "google.golang.org/protobuf/proto" | 11 | "google.golang.org/protobuf/proto" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 14 | "github.com/bnema/vev/internal/protocol" | 15 | "github.com/bnema/vev/internal/protocol" | ||
| 15 | "github.com/bnema/vev/internal/protocol/wire" | 16 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 16 | ) | 17 | ) | ||
| @@ -29,6 +30,10 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||||
| 29 | return nil, err | 30 | return nil, err | ||
| 30 | } | 31 | } | ||
| 31 | exact := exactTargetToWire(message.ExactTarget) | 32 | exact := exactTargetToWire(message.ExactTarget) | ||
| 33 | + | color, err := colorToWire(message.Color) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return nil, err | |||
| 36 | + | } | |||
| 32 | return &wire.Hello{ | 37 | return &wire.Hello{ | ||
| 33 | Version: uint32(message.Version), | 38 | Version: uint32(message.Version), | ||
| 34 | Intent: uint32(message.Intent), | 39 | Intent: uint32(message.Intent), | ||
| @@ -41,7 +46,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||||
| 41 | PixelHeight: pixelHeight, | 46 | PixelHeight: pixelHeight, | ||
| 42 | TermEnv: message.TermEnv, | 47 | TermEnv: message.TermEnv, | ||
| 43 | Cwd: message.Cwd, | 48 | Cwd: message.Cwd, | ||
| 44 | - | TrueColor: message.TrueColor, | 49 | + | Color: color, |
| 45 | MaxOutputInFlight: uint32(message.MaxOutputInFlight), | 50 | MaxOutputInFlight: uint32(message.MaxOutputInFlight), | ||
| 46 | Env: append([]string(nil), message.Env...), | 51 | Env: append([]string(nil), message.Env...), | ||
| 47 | SessionTarget: sessionTarget, | 52 | SessionTarget: sessionTarget, | ||
| @@ -57,6 +62,76 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||||
| 57 | }, nil | 62 | }, nil | ||
| 58 | } | 63 | } | ||
| 59 | 64 | ||||
| 65 | + | // colorToWire maps a color capability to its wire message. Unknown semantic | |||
| 66 | + | // values are never encoded. | |||
| 67 | + | func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) { | |||
| 68 | + | if !color.Valid() { | |||
| 69 | + | return nil, errProtoConvertRange | |||
| 70 | + | } | |||
| 71 | + | var mode wire.ColorMode | |||
| 72 | + | switch color.Mode { | |||
| 73 | + | case terminalcap.TrueColor: | |||
| 74 | + | mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR | |||
| 75 | + | case terminalcap.ANSI256: | |||
| 76 | + | mode = wire.ColorMode_COLOR_MODE_ANSI256 | |||
| 77 | + | case terminalcap.ANSI16: | |||
| 78 | + | mode = wire.ColorMode_COLOR_MODE_ANSI16 | |||
| 79 | + | case terminalcap.Monochrome: | |||
| 80 | + | mode = wire.ColorMode_COLOR_MODE_MONOCHROME | |||
| 81 | + | default: | |||
| 82 | + | return nil, errProtoConvertRange | |||
| 83 | + | } | |||
| 84 | + | var source wire.ColorSource | |||
| 85 | + | switch color.Source { | |||
| 86 | + | case terminalcap.SourceUnknown: | |||
| 87 | + | source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED | |||
| 88 | + | case terminalcap.SourceHeuristic: | |||
| 89 | + | source = wire.ColorSource_COLOR_SOURCE_HEURISTIC | |||
| 90 | + | case terminalcap.SourceDeclared: | |||
| 91 | + | source = wire.ColorSource_COLOR_SOURCE_DECLARED | |||
| 92 | + | case terminalcap.SourceForced: | |||
| 93 | + | source = wire.ColorSource_COLOR_SOURCE_FORCED | |||
| 94 | + | default: | |||
| 95 | + | return nil, errProtoConvertRange | |||
| 96 | + | } | |||
| 97 | + | return &wire.ColorCapabilities{Mode: mode, Source: source}, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // colorFromWire is strict: Hello must claim a color capability, so a missing | |||
| 101 | + | // message, an UNSPECIFIED mode, or any value outside the closed enums is a | |||
| 102 | + | // decode error. An UNSPECIFIED source is the explicit "unknown" source. | |||
| 103 | + | func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) { | |||
| 104 | + | if message == nil { | |||
| 105 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |||
| 106 | + | } | |||
| 107 | + | var color terminalcap.ColorCapabilities | |||
| 108 | + | switch message.GetMode() { | |||
| 109 | + | case wire.ColorMode_COLOR_MODE_TRUE_COLOR: | |||
| 110 | + | color.Mode = terminalcap.TrueColor | |||
| 111 | + | case wire.ColorMode_COLOR_MODE_ANSI256: | |||
| 112 | + | color.Mode = terminalcap.ANSI256 | |||
| 113 | + | case wire.ColorMode_COLOR_MODE_ANSI16: | |||
| 114 | + | color.Mode = terminalcap.ANSI16 | |||
| 115 | + | case wire.ColorMode_COLOR_MODE_MONOCHROME: | |||
| 116 | + | color.Mode = terminalcap.Monochrome | |||
| 117 | + | default: | |||
| 118 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |||
| 119 | + | } | |||
| 120 | + | switch message.GetSource() { | |||
| 121 | + | case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED: | |||
| 122 | + | color.Source = terminalcap.SourceUnknown | |||
| 123 | + | case wire.ColorSource_COLOR_SOURCE_HEURISTIC: | |||
| 124 | + | color.Source = terminalcap.SourceHeuristic | |||
| 125 | + | case wire.ColorSource_COLOR_SOURCE_DECLARED: | |||
| 126 | + | color.Source = terminalcap.SourceDeclared | |||
| 127 | + | case wire.ColorSource_COLOR_SOURCE_FORCED: | |||
| 128 | + | color.Source = terminalcap.SourceForced | |||
| 129 | + | default: | |||
| 130 | + | return terminalcap.ColorCapabilities{}, errProtoConvertRange | |||
| 131 | + | } | |||
| 132 | + | return color, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 60 | func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) { | 135 | func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) { | ||
| 61 | for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} { | 136 | for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} { | ||
| 62 | if value < 0 || value > math.MaxUint16 { | 137 | if value < 0 || value > math.MaxUint16 { | ||
| @@ -108,7 +183,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||||
| 108 | hello.PixelHeight = int(pixelHeight) | 183 | hello.PixelHeight = int(pixelHeight) | ||
| 109 | hello.TermEnv = message.GetTermEnv() | 184 | hello.TermEnv = message.GetTermEnv() | ||
| 110 | hello.Cwd = message.GetCwd() | 185 | hello.Cwd = message.GetCwd() | ||
| 111 | - | hello.TrueColor = message.GetTrueColor() | 186 | + | hello.Color, err = colorFromWire(message.GetColor()) |
| 187 | + | if err != nil { | |||
| 188 | + | return protocol.Hello{}, err | |||
| 189 | + | } | |||
| 112 | window, err := mustUint8(message.GetMaxOutputInFlight()) | 190 | window, err := mustUint8(message.GetMaxOutputInFlight()) | ||
| 113 | if err != nil { | 191 | if err != nil { | ||
| 114 | return protocol.Hello{}, err | 192 | return protocol.Hello{}, err | ||
M internal/adapters/sessionwire/proto_convert_test.go +96 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "google.golang.org/protobuf/proto" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol/wire" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| @@ -40,13 +41,15 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||
| 40 | 41 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 41 | 42 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true}, | |
| 42 | 43 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242}, | |
| 44 | + | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |
| 45 | + | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}}, | |
| 43 | 46 | protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")}, | |
| 44 | 47 | protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 45 | 48 | protocol.Detach{}, | |
| 46 | 49 | protocol.Ping{}, | |
| 47 | 50 | protocol.List{}, | |
| 48 | 51 | protocol.Kill{RequestID: 1, Name: "work"}, | |
| 49 | - | protocol.Theme{TrueColor: true}, | |
| 52 | + | protocol.Theme{SchemeKnown: true}, | |
| 50 | 53 | protocol.Ack{Epoch: 1, State: 1}, | |
| 51 | 54 | protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}, | |
| 52 | 55 | protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, | |
| @@ -353,3 +356,95 @@ func protoShortName(message any) string { | |||
| 353 | 356 | } | |
| 354 | 357 | return reflect.TypeOf(message).Name() | |
| 355 | 358 | } | |
| 359 | + | ||
| 360 | + | // TestProtoHelloColorWire pins the Hello color capability byte for byte for | |
| 361 | + | // every mode and source, and refuses truncated prefixes, trailing garbage, | |
| 362 | + | // and values outside the closed enums. | |
| 363 | + | func TestProtoHelloColorWire(t *testing.T) { | |
| 364 | + | modes := []struct { | |
| 365 | + | name string | |
| 366 | + | mode terminalcap.ColorMode | |
| 367 | + | code byte | |
| 368 | + | }{ | |
| 369 | + | {name: "truecolor", mode: terminalcap.TrueColor, code: 1}, | |
| 370 | + | {name: "ansi256", mode: terminalcap.ANSI256, code: 2}, | |
| 371 | + | {name: "ansi16", mode: terminalcap.ANSI16, code: 3}, | |
| 372 | + | {name: "monochrome", mode: terminalcap.Monochrome, code: 4}, | |
| 373 | + | } | |
| 374 | + | sources := []struct { | |
| 375 | + | name string | |
| 376 | + | source terminalcap.Source | |
| 377 | + | code byte | |
| 378 | + | }{ | |
| 379 | + | {name: "unknown", source: terminalcap.SourceUnknown, code: 0}, | |
| 380 | + | {name: "heuristic", source: terminalcap.SourceHeuristic, code: 1}, | |
| 381 | + | {name: "declared", source: terminalcap.SourceDeclared, code: 2}, | |
| 382 | + | {name: "forced", source: terminalcap.SourceForced, code: 3}, | |
| 383 | + | } | |
| 384 | + | for _, m := range modes { | |
| 385 | + | for _, src := range sources { | |
| 386 | + | t.Run(m.name+"/"+src.name, func(t *testing.T) { | |
| 387 | + | color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source} | |
| 388 | + | // The minimal attach Hello: version, intent, empty client id, cols, | |
| 389 | + | // rows, then color (field 26, length-delimited), preceded by the | |
| 390 | + | // oneof tag for Hello (field 1). | |
| 391 | + | colorBytes := []byte{0xD2, 0x01} | |
| 392 | + | if src.code == 0 { | |
| 393 | + | colorBytes = append(colorBytes, 0x02, 0x08, m.code) | |
| 394 | + | } else { | |
| 395 | + | colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code) | |
| 396 | + | } | |
| 397 | + | hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color} | |
| 398 | + | envelope, err := encodeProtoClient(hello) | |
| 399 | + | require.NoError(t, err) | |
| 400 | + | raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope) | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw) | |
| 403 | + | // ClientEnvelope tag 1 (Hello), then the Hello length. | |
| 404 | + | require.Equal(t, byte(0x0A), raw[0]) | |
| 405 | + | ||
| 406 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |
| 407 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |
| 408 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |
| 409 | + | decoded := &wire.ClientEnvelope{} | |
| 410 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |
| 411 | + | got, err := decodeProtoClient(decoded) | |
| 412 | + | require.NoError(t, err) | |
| 413 | + | require.Equal(t, hello, got) | |
| 414 | + | }) | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | t.Run("missing color is rejected", func(t *testing.T) { | |
| 419 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24} | |
| 420 | + | raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |
| 421 | + | require.NoError(t, err) | |
| 422 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid") | |
| 423 | + | decoded := &wire.ClientEnvelope{} | |
| 424 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |
| 425 | + | _, err = decodeProtoClient(decoded) | |
| 426 | + | require.Error(t, err) | |
| 427 | + | }) | |
| 428 | + | ||
| 429 | + | t.Run("unspecified mode on the wire is rejected", func(t *testing.T) { | |
| 430 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}} | |
| 431 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |
| 432 | + | require.Error(t, err) | |
| 433 | + | }) | |
| 434 | + | ||
| 435 | + | t.Run("unknown enum values are rejected", func(t *testing.T) { | |
| 436 | + | for _, color := range []*wire.ColorCapabilities{ | |
| 437 | + | {Mode: wire.ColorMode(5)}, | |
| 438 | + | {Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)}, | |
| 439 | + | } { | |
| 440 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color} | |
| 441 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |
| 442 | + | require.Error(t, err) | |
| 443 | + | } | |
| 444 | + | }) | |
| 445 | + | ||
| 446 | + | t.Run("invalid semantic color is never encoded", func(t *testing.T) { | |
| 447 | + | _, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}}) | |
| 448 | + | require.Error(t, err) | |
| 449 | + | }) | |
| 450 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "google.golang.org/protobuf/proto" | 10 | "google.golang.org/protobuf/proto" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 13 | "github.com/bnema/vev/internal/protocol" | 14 | "github.com/bnema/vev/internal/protocol" | ||
| 14 | "github.com/bnema/vev/internal/protocol/wire" | 15 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| @@ -40,13 +41,15 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||||
| 40 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}, | 41 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}, | ||
| 41 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true}, | 42 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true}, | ||
| 42 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242}, | 43 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242}, | ||
| 44 | + | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |||
| 45 | + | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}}, | |||
| 43 | protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")}, | 46 | protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")}, | ||
| 44 | protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, | 47 | protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, | ||
| 45 | protocol.Detach{}, | 48 | protocol.Detach{}, | ||
| 46 | protocol.Ping{}, | 49 | protocol.Ping{}, | ||
| 47 | protocol.List{}, | 50 | protocol.List{}, | ||
| 48 | protocol.Kill{RequestID: 1, Name: "work"}, | 51 | protocol.Kill{RequestID: 1, Name: "work"}, | ||
| 49 | - | protocol.Theme{TrueColor: true}, | 52 | + | protocol.Theme{SchemeKnown: true}, |
| 50 | protocol.Ack{Epoch: 1, State: 1}, | 53 | protocol.Ack{Epoch: 1, State: 1}, | ||
| 51 | protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}, | 54 | protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}, | ||
| 52 | protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, | 55 | protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, | ||
| @@ -353,3 +356,95 @@ func protoShortName(message any) string { | |||||
| 353 | } | 356 | } | ||
| 354 | return reflect.TypeOf(message).Name() | 357 | return reflect.TypeOf(message).Name() | ||
| 355 | } | 358 | } | ||
| 359 | + | ||||
| 360 | + | // TestProtoHelloColorWire pins the Hello color capability byte for byte for | |||
| 361 | + | // every mode and source, and refuses truncated prefixes, trailing garbage, | |||
| 362 | + | // and values outside the closed enums. | |||
| 363 | + | func TestProtoHelloColorWire(t *testing.T) { | |||
| 364 | + | modes := []struct { | |||
| 365 | + | name string | |||
| 366 | + | mode terminalcap.ColorMode | |||
| 367 | + | code byte | |||
| 368 | + | }{ | |||
| 369 | + | {name: "truecolor", mode: terminalcap.TrueColor, code: 1}, | |||
| 370 | + | {name: "ansi256", mode: terminalcap.ANSI256, code: 2}, | |||
| 371 | + | {name: "ansi16", mode: terminalcap.ANSI16, code: 3}, | |||
| 372 | + | {name: "monochrome", mode: terminalcap.Monochrome, code: 4}, | |||
| 373 | + | } | |||
| 374 | + | sources := []struct { | |||
| 375 | + | name string | |||
| 376 | + | source terminalcap.Source | |||
| 377 | + | code byte | |||
| 378 | + | }{ | |||
| 379 | + | {name: "unknown", source: terminalcap.SourceUnknown, code: 0}, | |||
| 380 | + | {name: "heuristic", source: terminalcap.SourceHeuristic, code: 1}, | |||
| 381 | + | {name: "declared", source: terminalcap.SourceDeclared, code: 2}, | |||
| 382 | + | {name: "forced", source: terminalcap.SourceForced, code: 3}, | |||
| 383 | + | } | |||
| 384 | + | for _, m := range modes { | |||
| 385 | + | for _, src := range sources { | |||
| 386 | + | t.Run(m.name+"/"+src.name, func(t *testing.T) { | |||
| 387 | + | color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source} | |||
| 388 | + | // The minimal attach Hello: version, intent, empty client id, cols, | |||
| 389 | + | // rows, then color (field 26, length-delimited), preceded by the | |||
| 390 | + | // oneof tag for Hello (field 1). | |||
| 391 | + | colorBytes := []byte{0xD2, 0x01} | |||
| 392 | + | if src.code == 0 { | |||
| 393 | + | colorBytes = append(colorBytes, 0x02, 0x08, m.code) | |||
| 394 | + | } else { | |||
| 395 | + | colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code) | |||
| 396 | + | } | |||
| 397 | + | hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color} | |||
| 398 | + | envelope, err := encodeProtoClient(hello) | |||
| 399 | + | require.NoError(t, err) | |||
| 400 | + | raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope) | |||
| 401 | + | require.NoError(t, err) | |||
| 402 | + | require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw) | |||
| 403 | + | // ClientEnvelope tag 1 (Hello), then the Hello length. | |||
| 404 | + | require.Equal(t, byte(0x0A), raw[0]) | |||
| 405 | + | ||||
| 406 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |||
| 407 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |||
| 408 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |||
| 409 | + | decoded := &wire.ClientEnvelope{} | |||
| 410 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |||
| 411 | + | got, err := decodeProtoClient(decoded) | |||
| 412 | + | require.NoError(t, err) | |||
| 413 | + | require.Equal(t, hello, got) | |||
| 414 | + | }) | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | t.Run("missing color is rejected", func(t *testing.T) { | |||
| 419 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24} | |||
| 420 | + | raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |||
| 421 | + | require.NoError(t, err) | |||
| 422 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid") | |||
| 423 | + | decoded := &wire.ClientEnvelope{} | |||
| 424 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |||
| 425 | + | _, err = decodeProtoClient(decoded) | |||
| 426 | + | require.Error(t, err) | |||
| 427 | + | }) | |||
| 428 | + | ||||
| 429 | + | t.Run("unspecified mode on the wire is rejected", func(t *testing.T) { | |||
| 430 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}} | |||
| 431 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |||
| 432 | + | require.Error(t, err) | |||
| 433 | + | }) | |||
| 434 | + | ||||
| 435 | + | t.Run("unknown enum values are rejected", func(t *testing.T) { | |||
| 436 | + | for _, color := range []*wire.ColorCapabilities{ | |||
| 437 | + | {Mode: wire.ColorMode(5)}, | |||
| 438 | + | {Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)}, | |||
| 439 | + | } { | |||
| 440 | + | message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color} | |||
| 441 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}}) | |||
| 442 | + | require.Error(t, err) | |||
| 443 | + | } | |||
| 444 | + | }) | |||
| 445 | + | ||||
| 446 | + | t.Run("invalid semantic color is never encoded", func(t *testing.T) { | |||
| 447 | + | _, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}}) | |||
| 448 | + | require.Error(t, err) | |||
| 449 | + | }) | |||
| 450 | + | } | |||
M internal/adapters/sessionwire/server_test.go +1 -1
| @@ -30,7 +30,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 30 | 30 | {name: "ping", want: protocol.Ping{}}, | |
| 31 | 31 | {name: "list", want: protocol.List{}}, | |
| 32 | 32 | {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}}, | |
| 33 | - | {name: "theme", want: protocol.Theme{TrueColor: true}}, | |
| 33 | + | {name: "theme", want: protocol.Theme{SchemeKnown: true}}, | |
| 34 | 34 | {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}}, | |
| 35 | 35 | {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}}, | |
| 36 | 36 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | |
| @@ -30,7 +30,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||||
| 30 | {name: "ping", want: protocol.Ping{}}, | 30 | {name: "ping", want: protocol.Ping{}}, | ||
| 31 | {name: "list", want: protocol.List{}}, | 31 | {name: "list", want: protocol.List{}}, | ||
| 32 | {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}}, | 32 | {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}}, | ||
| 33 | - | {name: "theme", want: protocol.Theme{TrueColor: true}}, | 33 | + | {name: "theme", want: protocol.Theme{SchemeKnown: true}}, |
| 34 | {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}}, | 34 | {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}}, | ||
| 35 | {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}}, | 35 | {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}}, | ||
| 36 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | 36 | {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}}, | ||
M internal/app/broker_client.go +82 -10
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "log/slog" | |
| 8 | 8 | "os" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -283,7 +284,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 283 | 284 | if reader == nil { | |
| 284 | 285 | reader = clipboard.New() | |
| 285 | 286 | } | |
| 286 | - | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | |
| 287 | + | picker := client.NewPicker(clk, 0, attachmentEnv.Color) | |
| 287 | 288 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | |
| 288 | 289 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 289 | 290 | Logger: cfg.Logger, | |
| @@ -612,20 +613,91 @@ var ( | |||
| 612 | 613 | // process environment themselves. | |
| 613 | 614 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | |
| 614 | 615 | 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()), | |
| 616 | + | TermEnv: os.Getenv("TERM"), | |
| 617 | + | Cwd: currentWorkingDirectory(), | |
| 618 | + | Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 619 | + | PID: uint32(os.Getpid()), | |
| 619 | 620 | } | |
| 620 | 621 | } | |
| 621 | 622 | ||
| 622 | - | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | |
| 623 | - | // 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. | |
| 625 | - | func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |
| 623 | + | // detectTerminalColor derives the color capability from the process | |
| 624 | + | // environment. Explicit TERM/COLORTERM values override env entries. | |
| 625 | + | func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities { | |
| 626 | + | detectionEnv := make([]string, 0, len(env)+2) | |
| 627 | + | detectionEnv = append(detectionEnv, env...) | |
| 628 | + | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | |
| 629 | + | return terminalcap.Detect(detectionEnv).Color | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // envColors is the client-side color-mode override. It wins over the | |
| 633 | + | // terminal.colors config key and is read by each client process; the daemon | |
| 634 | + | // never sees it except as the Forced Hello.Color it produces. | |
| 635 | + | const envColors = "VEV_COLORS" | |
| 636 | + | ||
| 637 | + | // virtualTerminalColor is the color capability declared by clients whose | |
| 638 | + | // terminal is an in-process RGB-capable VT mirror (the browser terminal and the | |
| 639 | + | // headless UI driver) rather than the gateway process' controlling terminal. | |
| 640 | + | // It is forced so the gateway's own TERM/COLORTERM never downgrades them. | |
| 641 | + | var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced} | |
| 642 | + | ||
| 643 | + | // virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a | |
| 644 | + | // client that renders into an RGB-capable virtual terminal: it declares forced | |
| 645 | + | // truecolor instead of detecting from the gateway process environment. | |
| 646 | + | func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment { | |
| 647 | + | env := terminalAttachmentEnvironment() | |
| 648 | + | env.Color = virtualTerminalColor | |
| 649 | + | return env | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // resolveColorOverride applies the explicit color override on top of the | |
| 653 | + | // detected capability: a valid VEV_COLORS wins over terminal.colors, and | |
| 654 | + | // "auto" at either level means detection. An empty VEV_COLORS is unset, so the | |
| 655 | + | // config value still applies. An invalid VEV_COLORS is reported through log | |
| 656 | + | // and ignored. A forced result carries SourceForced. | |
| 657 | + | func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities { | |
| 658 | + | if strings.TrimSpace(envValue) != "" { | |
| 659 | + | mode, auto, err := terminalcap.ParseColorMode(envValue) | |
| 660 | + | switch { | |
| 661 | + | case err != nil: | |
| 662 | + | if log != nil { | |
| 663 | + | log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err) | |
| 664 | + | } | |
| 665 | + | case auto: | |
| 666 | + | return detected | |
| 667 | + | default: | |
| 668 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | if cfg.ColorsSet { | |
| 672 | + | return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced} | |
| 673 | + | } | |
| 674 | + | return detected | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a | |
| 678 | + | // client whose terminal is the user's real one: the detected color capability | |
| 679 | + | // yields to VEV_COLORS and terminal.colors. The single resolved value feeds | |
| 680 | + | // both the picker and every Hello. | |
| 681 | + | func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment { | |
| 626 | 682 | env := terminalAttachmentEnvironment() | |
| 683 | + | env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log) | |
| 684 | + | return env | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // configuredTerminalAttachmentEnvironment loads the user's config and returns | |
| 688 | + | // userTerminalAttachmentEnvironment for it. | |
| 689 | + | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |
| 690 | + | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | |
| 694 | + | // for a client that owns a real outer terminal: it probes that terminal once | |
| 695 | + | // and applies the user's keyboard setting. Virtual terminals never probe. | |
| 696 | + | func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |
| 697 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) | |
| 698 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |
| 627 | 699 | env.ProbeTerminal = true | |
| 628 | - | env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol | |
| 700 | + | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | |
| 629 | 701 | return env | |
| 630 | 702 | } | |
| 631 | 703 | ||
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "log/slog" | 7 | "log/slog" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| @@ -283,7 +284,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 283 | if reader == nil { | 284 | if reader == nil { | ||
| 284 | reader = clipboard.New() | 285 | reader = clipboard.New() | ||
| 285 | } | 286 | } | ||
| 286 | - | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | 287 | + | picker := client.NewPicker(clk, 0, attachmentEnv.Color) |
| 287 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | 288 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | ||
| 288 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | 289 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | ||
| 289 | Logger: cfg.Logger, | 290 | Logger: cfg.Logger, | ||
| @@ -612,20 +613,91 @@ var ( | |||||
| 612 | // process environment themselves. | 613 | // process environment themselves. | ||
| 613 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | 614 | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | ||
| 614 | return client.AttachmentEnvironment{ | 615 | return client.AttachmentEnvironment{ | ||
| 615 | - | TermEnv: os.Getenv("TERM"), | 616 | + | TermEnv: os.Getenv("TERM"), |
| 616 | - | Cwd: currentWorkingDirectory(), | 617 | + | Cwd: currentWorkingDirectory(), |
| 617 | - | TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | 618 | + | Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), |
| 618 | - | PID: uint32(os.Getpid()), | 619 | + | PID: uint32(os.Getpid()), |
| 619 | } | 620 | } | ||
| 620 | } | 621 | } | ||
| 621 | 622 | ||||
| 622 | - | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | 623 | + | // detectTerminalColor derives the color capability from the process |
| 623 | - | // a client that owns a real outer terminal: it probes that terminal once and | 624 | + | // environment. Explicit TERM/COLORTERM values override env entries. |
| 624 | - | // applies the user's keyboard setting. Virtual terminals never probe. | 625 | + | func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities { |
| 625 | - | func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | 626 | + | detectionEnv := make([]string, 0, len(env)+2) |
| 627 | + | detectionEnv = append(detectionEnv, env...) | |||
| 628 | + | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | |||
| 629 | + | return terminalcap.Detect(detectionEnv).Color | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | // envColors is the client-side color-mode override. It wins over the | |||
| 633 | + | // terminal.colors config key and is read by each client process; the daemon | |||
| 634 | + | // never sees it except as the Forced Hello.Color it produces. | |||
| 635 | + | const envColors = "VEV_COLORS" | |||
| 636 | + | ||||
| 637 | + | // virtualTerminalColor is the color capability declared by clients whose | |||
| 638 | + | // terminal is an in-process RGB-capable VT mirror (the browser terminal and the | |||
| 639 | + | // headless UI driver) rather than the gateway process' controlling terminal. | |||
| 640 | + | // It is forced so the gateway's own TERM/COLORTERM never downgrades them. | |||
| 641 | + | var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced} | |||
| 642 | + | ||||
| 643 | + | // virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a | |||
| 644 | + | // client that renders into an RGB-capable virtual terminal: it declares forced | |||
| 645 | + | // truecolor instead of detecting from the gateway process environment. | |||
| 646 | + | func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment { | |||
| 647 | + | env := terminalAttachmentEnvironment() | |||
| 648 | + | env.Color = virtualTerminalColor | |||
| 649 | + | return env | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | // resolveColorOverride applies the explicit color override on top of the | |||
| 653 | + | // detected capability: a valid VEV_COLORS wins over terminal.colors, and | |||
| 654 | + | // "auto" at either level means detection. An empty VEV_COLORS is unset, so the | |||
| 655 | + | // config value still applies. An invalid VEV_COLORS is reported through log | |||
| 656 | + | // and ignored. A forced result carries SourceForced. | |||
| 657 | + | func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities { | |||
| 658 | + | if strings.TrimSpace(envValue) != "" { | |||
| 659 | + | mode, auto, err := terminalcap.ParseColorMode(envValue) | |||
| 660 | + | switch { | |||
| 661 | + | case err != nil: | |||
| 662 | + | if log != nil { | |||
| 663 | + | log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err) | |||
| 664 | + | } | |||
| 665 | + | case auto: | |||
| 666 | + | return detected | |||
| 667 | + | default: | |||
| 668 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | if cfg.ColorsSet { | |||
| 672 | + | return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced} | |||
| 673 | + | } | |||
| 674 | + | return detected | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | // userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a | |||
| 678 | + | // client whose terminal is the user's real one: the detected color capability | |||
| 679 | + | // yields to VEV_COLORS and terminal.colors. The single resolved value feeds | |||
| 680 | + | // both the picker and every Hello. | |||
| 681 | + | func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment { | |||
| 626 | env := terminalAttachmentEnvironment() | 682 | env := terminalAttachmentEnvironment() | ||
| 683 | + | env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log) | |||
| 684 | + | return env | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | // configuredTerminalAttachmentEnvironment loads the user's config and returns | |||
| 688 | + | // userTerminalAttachmentEnvironment for it. | |||
| 689 | + | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |||
| 690 | + | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | |||
| 694 | + | // for a client that owns a real outer terminal: it probes that terminal once | |||
| 695 | + | // and applies the user's keyboard setting. Virtual terminals never probe. | |||
| 696 | + | func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |||
| 697 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) | |||
| 698 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |||
| 627 | env.ProbeTerminal = true | 699 | env.ProbeTerminal = true | ||
| 628 | - | env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol | 700 | + | env.KittyKeyboard = cfg.Keyboard.KittyProtocol |
| 629 | return env | 701 | return env | ||
| 630 | } | 702 | } | ||
| 631 | 703 | ||||
M internal/app/broker_client_test.go +99 -0
| @@ -3,9 +3,11 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "io" | |
| 10 | + | "log/slog" | |
| 9 | 11 | "os" | |
| 10 | 12 | "slices" | |
| 11 | 13 | "sync" | |
| @@ -18,6 +20,7 @@ import ( | |||
| 18 | 20 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 19 | 21 | "github.com/bnema/vev/internal/adapters/clock" | |
| 20 | 22 | "github.com/bnema/vev/internal/domain" | |
| 23 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 21 | 24 | "github.com/bnema/vev/internal/ports" | |
| 22 | 25 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 23 | 26 | "github.com/bnema/vev/internal/protocol" | |
| @@ -1162,3 +1165,99 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po | |||
| 1162 | 1165 | } | |
| 1163 | 1166 | } | |
| 1164 | 1167 | } | |
| 1168 | + | ||
| 1169 | + | func TestDetectTerminalColor(t *testing.T) { | |
| 1170 | + | tests := []struct { | |
| 1171 | + | name string | |
| 1172 | + | term string | |
| 1173 | + | colorTerm string | |
| 1174 | + | env []string | |
| 1175 | + | want terminalcap.ColorCapabilities | |
| 1176 | + | }{ | |
| 1177 | + | {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}}, | |
| 1178 | + | {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |
| 1179 | + | {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, | |
| 1180 | + | {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |
| 1181 | + | } | |
| 1182 | + | for _, tt := range tests { | |
| 1183 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1184 | + | require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env)) | |
| 1185 | + | }) | |
| 1186 | + | } | |
| 1187 | + | } | |
| 1188 | + | ||
| 1189 | + | func TestResolveColorOverride(t *testing.T) { | |
| 1190 | + | detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared} | |
| 1191 | + | forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities { | |
| 1192 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |
| 1193 | + | } | |
| 1194 | + | tests := []struct { | |
| 1195 | + | name string | |
| 1196 | + | env string | |
| 1197 | + | cfg domain.TerminalConfig | |
| 1198 | + | want terminalcap.ColorCapabilities | |
| 1199 | + | wantWarn bool | |
| 1200 | + | }{ | |
| 1201 | + | {name: "nothing set uses detection", want: detected}, | |
| 1202 | + | {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected}, | |
| 1203 | + | {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)}, | |
| 1204 | + | {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)}, | |
| 1205 | + | {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)}, | |
| 1206 | + | {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)}, | |
| 1207 | + | {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)}, | |
| 1208 | + | {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected}, | |
| 1209 | + | {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)}, | |
| 1210 | + | {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true}, | |
| 1211 | + | {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true}, | |
| 1212 | + | } | |
| 1213 | + | for _, tt := range tests { | |
| 1214 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1215 | + | var logs bytes.Buffer | |
| 1216 | + | log := slog.New(slog.NewTextHandler(&logs, nil)) | |
| 1217 | + | require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log)) | |
| 1218 | + | if tt.wantWarn { | |
| 1219 | + | require.Contains(t, logs.String(), "VEV_COLORS") | |
| 1220 | + | } else { | |
| 1221 | + | require.Empty(t, logs.String()) | |
| 1222 | + | } | |
| 1223 | + | }) | |
| 1224 | + | } | |
| 1225 | + | t.Run("nil logger tolerates invalid env", func(t *testing.T) { | |
| 1226 | + | require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil)) | |
| 1227 | + | }) | |
| 1228 | + | } | |
| 1229 | + | ||
| 1230 | + | func TestTerminalAttachmentEnvironmentColor(t *testing.T) { | |
| 1231 | + | forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities { | |
| 1232 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |
| 1233 | + | } | |
| 1234 | + | tests := []struct { | |
| 1235 | + | name string | |
| 1236 | + | term string | |
| 1237 | + | colors string | |
| 1238 | + | cfg domain.TerminalConfig | |
| 1239 | + | wantUser terminalcap.ColorCapabilities | |
| 1240 | + | }{ | |
| 1241 | + | {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}}, | |
| 1242 | + | {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}}, | |
| 1243 | + | {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)}, | |
| 1244 | + | {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)}, | |
| 1245 | + | } | |
| 1246 | + | for _, tt := range tests { | |
| 1247 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1248 | + | t.Setenv("TERM", tt.term) | |
| 1249 | + | t.Setenv("COLORTERM", "") | |
| 1250 | + | t.Setenv("KITTY_WINDOW_ID", "") | |
| 1251 | + | t.Setenv("KITTY_PID", "") | |
| 1252 | + | t.Setenv("KITTY_LISTEN_ON", "") | |
| 1253 | + | t.Setenv(envColors, tt.colors) | |
| 1254 | + | ||
| 1255 | + | require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color) | |
| 1256 | + | // Web and headless UI-driver clients render into RGB VT mirrors and | |
| 1257 | + | // ignore both the gateway process terminal and the user override. | |
| 1258 | + | virtual := virtualTerminalAttachmentEnvironment() | |
| 1259 | + | require.Equal(t, forced(terminalcap.TrueColor), virtual.Color) | |
| 1260 | + | require.False(t, virtual.ProbeTerminal) | |
| 1261 | + | }) | |
| 1262 | + | } | |
| 1263 | + | } | |
| @@ -3,9 +3,11 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "errors" | 8 | "errors" | ||
| 8 | "io" | 9 | "io" | ||
| 10 | + | "log/slog" | |||
| 9 | "os" | 11 | "os" | ||
| 10 | "slices" | 12 | "slices" | ||
| 11 | "sync" | 13 | "sync" | ||
| @@ -18,6 +20,7 @@ import ( | |||||
| 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | 20 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 19 | "github.com/bnema/vev/internal/adapters/clock" | 21 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 20 | "github.com/bnema/vev/internal/domain" | 22 | "github.com/bnema/vev/internal/domain" | ||
| 23 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 21 | "github.com/bnema/vev/internal/ports" | 24 | "github.com/bnema/vev/internal/ports" | ||
| 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 25 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 23 | "github.com/bnema/vev/internal/protocol" | 26 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -1162,3 +1165,99 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po | |||||
| 1162 | } | 1165 | } | ||
| 1163 | } | 1166 | } | ||
| 1164 | } | 1167 | } | ||
| 1168 | + | ||||
| 1169 | + | func TestDetectTerminalColor(t *testing.T) { | |||
| 1170 | + | tests := []struct { | |||
| 1171 | + | name string | |||
| 1172 | + | term string | |||
| 1173 | + | colorTerm string | |||
| 1174 | + | env []string | |||
| 1175 | + | want terminalcap.ColorCapabilities | |||
| 1176 | + | }{ | |||
| 1177 | + | {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}}, | |||
| 1178 | + | {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |||
| 1179 | + | {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, | |||
| 1180 | + | {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |||
| 1181 | + | } | |||
| 1182 | + | for _, tt := range tests { | |||
| 1183 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1184 | + | require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env)) | |||
| 1185 | + | }) | |||
| 1186 | + | } | |||
| 1187 | + | } | |||
| 1188 | + | ||||
| 1189 | + | func TestResolveColorOverride(t *testing.T) { | |||
| 1190 | + | detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared} | |||
| 1191 | + | forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities { | |||
| 1192 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |||
| 1193 | + | } | |||
| 1194 | + | tests := []struct { | |||
| 1195 | + | name string | |||
| 1196 | + | env string | |||
| 1197 | + | cfg domain.TerminalConfig | |||
| 1198 | + | want terminalcap.ColorCapabilities | |||
| 1199 | + | wantWarn bool | |||
| 1200 | + | }{ | |||
| 1201 | + | {name: "nothing set uses detection", want: detected}, | |||
| 1202 | + | {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected}, | |||
| 1203 | + | {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)}, | |||
| 1204 | + | {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)}, | |||
| 1205 | + | {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)}, | |||
| 1206 | + | {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)}, | |||
| 1207 | + | {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)}, | |||
| 1208 | + | {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected}, | |||
| 1209 | + | {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)}, | |||
| 1210 | + | {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true}, | |||
| 1211 | + | {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true}, | |||
| 1212 | + | } | |||
| 1213 | + | for _, tt := range tests { | |||
| 1214 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1215 | + | var logs bytes.Buffer | |||
| 1216 | + | log := slog.New(slog.NewTextHandler(&logs, nil)) | |||
| 1217 | + | require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log)) | |||
| 1218 | + | if tt.wantWarn { | |||
| 1219 | + | require.Contains(t, logs.String(), "VEV_COLORS") | |||
| 1220 | + | } else { | |||
| 1221 | + | require.Empty(t, logs.String()) | |||
| 1222 | + | } | |||
| 1223 | + | }) | |||
| 1224 | + | } | |||
| 1225 | + | t.Run("nil logger tolerates invalid env", func(t *testing.T) { | |||
| 1226 | + | require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil)) | |||
| 1227 | + | }) | |||
| 1228 | + | } | |||
| 1229 | + | ||||
| 1230 | + | func TestTerminalAttachmentEnvironmentColor(t *testing.T) { | |||
| 1231 | + | forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities { | |||
| 1232 | + | return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced} | |||
| 1233 | + | } | |||
| 1234 | + | tests := []struct { | |||
| 1235 | + | name string | |||
| 1236 | + | term string | |||
| 1237 | + | colors string | |||
| 1238 | + | cfg domain.TerminalConfig | |||
| 1239 | + | wantUser terminalcap.ColorCapabilities | |||
| 1240 | + | }{ | |||
| 1241 | + | {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}}, | |||
| 1242 | + | {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}}, | |||
| 1243 | + | {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)}, | |||
| 1244 | + | {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)}, | |||
| 1245 | + | } | |||
| 1246 | + | for _, tt := range tests { | |||
| 1247 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1248 | + | t.Setenv("TERM", tt.term) | |||
| 1249 | + | t.Setenv("COLORTERM", "") | |||
| 1250 | + | t.Setenv("KITTY_WINDOW_ID", "") | |||
| 1251 | + | t.Setenv("KITTY_PID", "") | |||
| 1252 | + | t.Setenv("KITTY_LISTEN_ON", "") | |||
| 1253 | + | t.Setenv(envColors, tt.colors) | |||
| 1254 | + | ||||
| 1255 | + | require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color) | |||
| 1256 | + | // Web and headless UI-driver clients render into RGB VT mirrors and | |||
| 1257 | + | // ignore both the gateway process terminal and the user override. | |||
| 1258 | + | virtual := virtualTerminalAttachmentEnvironment() | |||
| 1259 | + | require.Equal(t, forced(terminalcap.TrueColor), virtual.Color) | |||
| 1260 | + | require.False(t, virtual.ProbeTerminal) | |||
| 1261 | + | }) | |||
| 1262 | + | } | |||
| 1263 | + | } | |||
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/app/daemon_integration_test.go +2 -1
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 29 | 29 | "github.com/bnema/vev/internal/adapters/snapshot" | |
| 30 | 30 | "github.com/bnema/vev/internal/domain" | |
| 31 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 31 | 32 | "github.com/bnema/vev/internal/persist" | |
| 32 | 33 | "github.com/bnema/vev/internal/ports" | |
| 33 | 34 | "github.com/bnema/vev/internal/protocol" | |
| @@ -115,7 +116,7 @@ func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, | |||
| 115 | 116 | raw, err := ipc.DialContext(context.Background(), dir) | |
| 116 | 117 | require.NoError(t, err) | |
| 117 | 118 | conn := sessionwire.NewClientConnection(raw) | |
| 118 | - | hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env} | |
| 119 | + | hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}, Env: env} | |
| 119 | 120 | require.NoError(t, conn.SendClient(hello)) | |
| 120 | 121 | p := recvTypedPump(conn) | |
| 121 | 122 | select { | |
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | "github.com/bnema/vev/internal/adapters/sessionwire" | 28 | "github.com/bnema/vev/internal/adapters/sessionwire" | ||
| 29 | "github.com/bnema/vev/internal/adapters/snapshot" | 29 | "github.com/bnema/vev/internal/adapters/snapshot" | ||
| 30 | "github.com/bnema/vev/internal/domain" | 30 | "github.com/bnema/vev/internal/domain" | ||
| 31 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 31 | "github.com/bnema/vev/internal/persist" | 32 | "github.com/bnema/vev/internal/persist" | ||
| 32 | "github.com/bnema/vev/internal/ports" | 33 | "github.com/bnema/vev/internal/ports" | ||
| 33 | "github.com/bnema/vev/internal/protocol" | 34 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -115,7 +116,7 @@ func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, | |||||
| 115 | raw, err := ipc.DialContext(context.Background(), dir) | 116 | raw, err := ipc.DialContext(context.Background(), dir) | ||
| 116 | require.NoError(t, err) | 117 | require.NoError(t, err) | ||
| 117 | conn := sessionwire.NewClientConnection(raw) | 118 | conn := sessionwire.NewClientConnection(raw) | ||
| 118 | - | hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env} | 119 | + | hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}, Env: env} |
| 119 | require.NoError(t, conn.SendClient(hello)) | 120 | require.NoError(t, conn.SendClient(hello)) | ||
| 120 | p := recvTypedPump(conn) | 121 | p := recvTypedPump(conn) | ||
| 121 | select { | 122 | select { | ||
M internal/app/ui_driver.go +2 -2
| @@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error { | |||
| 240 | 240 | defer terminal.Close() | |
| 241 | 241 | ui := client.NewUI(terminal, clk) | |
| 242 | 242 | sessionEnv := terminalSessionEnvironment() | |
| 243 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 243 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() | |
| 244 | 244 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 245 | 245 | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 246 | 246 | Logger: log, | |
| @@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget | |||
| 495 | 495 | return err | |
| 496 | 496 | } | |
| 497 | 497 | sessionEnv := terminalSessionEnvironment() | |
| 498 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 498 | + | attachmentEnv := configuredTerminalAttachmentEnvironment(log) | |
| 499 | 499 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 500 | 500 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 501 | 501 | Logger: log, | |
| @@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error { | |||||
| 240 | defer terminal.Close() | 240 | defer terminal.Close() | ||
| 241 | ui := client.NewUI(terminal, clk) | 241 | ui := client.NewUI(terminal, clk) | ||
| 242 | sessionEnv := terminalSessionEnvironment() | 242 | sessionEnv := terminalSessionEnvironment() | ||
| 243 | - | attachmentEnv := terminalAttachmentEnvironment() | 243 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() |
| 244 | attachmentEnv.Cwd = sessionEnv.Cwd | 244 | attachmentEnv.Cwd = sessionEnv.Cwd | ||
| 245 | return runUIDriverClient(ctx, brokerClientConfig{ | 245 | return runUIDriverClient(ctx, brokerClientConfig{ | ||
| 246 | Logger: log, | 246 | Logger: log, | ||
| @@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget | |||||
| 495 | return err | 495 | return err | ||
| 496 | } | 496 | } | ||
| 497 | sessionEnv := terminalSessionEnvironment() | 497 | sessionEnv := terminalSessionEnvironment() | ||
| 498 | - | attachmentEnv := terminalAttachmentEnvironment() | 498 | + | attachmentEnv := configuredTerminalAttachmentEnvironment(log) |
| 499 | attachmentEnv.Cwd = sessionEnv.Cwd | 499 | attachmentEnv.Cwd = sessionEnv.Cwd | ||
| 500 | return runBrokerClient(ctx, brokerClientConfig{ | 500 | return runBrokerClient(ctx, brokerClientConfig{ | ||
| 501 | Logger: log, | 501 | Logger: log, | ||
M internal/app/web_daemon.go +1 -1
| @@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna | |||
| 194 | 194 | func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error { | |
| 195 | 195 | callbacks := terminalBrokerCallbacks() | |
| 196 | 196 | sessionEnv := terminalSessionEnvironment() | |
| 197 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 197 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() | |
| 198 | 198 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 199 | 199 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 200 | 200 | Connector: webTerminalConnector(), | |
| @@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna | |||||
| 194 | func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error { | 194 | func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error { | ||
| 195 | callbacks := terminalBrokerCallbacks() | 195 | callbacks := terminalBrokerCallbacks() | ||
| 196 | sessionEnv := terminalSessionEnvironment() | 196 | sessionEnv := terminalSessionEnvironment() | ||
| 197 | - | attachmentEnv := terminalAttachmentEnvironment() | 197 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() |
| 198 | attachmentEnv.Cwd = sessionEnv.Cwd | 198 | attachmentEnv.Cwd = sessionEnv.Cwd | ||
| 199 | return runBrokerClient(ctx, brokerClientConfig{ | 199 | return runBrokerClient(ctx, brokerClientConfig{ | ||
| 200 | Connector: webTerminalConnector(), | 200 | Connector: webTerminalConnector(), | ||
M internal/domain/config.go +16 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | 10 | const MinBarInterval = time.Second | |
| @@ -98,6 +102,16 @@ type KeyboardConfig struct { | |||
| 98 | 102 | KittyProtocol bool | |
| 99 | 103 | } | |
| 100 | 104 | ||
| 105 | + | // TerminalConfig contains client-side outer-terminal settings. It is read by | |
| 106 | + | // each client at startup and never applied by the daemon, so a change takes | |
| 107 | + | // effect on the next client process. | |
| 108 | + | type TerminalConfig struct { | |
| 109 | + | // Colors is the forced color mode when ColorsSet is true. A false | |
| 110 | + | // ColorsSet (terminal.colors = auto, the default) uses detection. | |
| 111 | + | Colors terminalcap.ColorMode | |
| 112 | + | ColorsSet bool | |
| 113 | + | } | |
| 114 | + | ||
| 101 | 115 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | |
| 102 | 116 | // only enforces these application-selected limits. Zero megabytes disables | |
| 103 | 117 | // history; zero lines removes the additional line ceiling. | |
| @@ -139,6 +153,7 @@ type Config struct { | |||
| 139 | 153 | Tabs TabsConfig | |
| 140 | 154 | Ephemeral EphemeralConfig | |
| 141 | 155 | Keyboard KeyboardConfig | |
| 156 | + | Terminal TerminalConfig | |
| 142 | 157 | Scrollback ScrollbackConfig | |
| 143 | 158 | } | |
| 144 | 159 | ||
| @@ -1,6 +1,10 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | - | import "time" | 3 | + | import ( |
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | // MinBarInterval is the minimum supported bar refresh interval. | 9 | // MinBarInterval is the minimum supported bar refresh interval. | ||
| 6 | const MinBarInterval = time.Second | 10 | const MinBarInterval = time.Second | ||
| @@ -98,6 +102,16 @@ type KeyboardConfig struct { | |||||
| 98 | KittyProtocol bool | 102 | KittyProtocol bool | ||
| 99 | } | 103 | } | ||
| 100 | 104 | ||||
| 105 | + | // TerminalConfig contains client-side outer-terminal settings. It is read by | |||
| 106 | + | // each client at startup and never applied by the daemon, so a change takes | |||
| 107 | + | // effect on the next client process. | |||
| 108 | + | type TerminalConfig struct { | |||
| 109 | + | // Colors is the forced color mode when ColorsSet is true. A false | |||
| 110 | + | // ColorsSet (terminal.colors = auto, the default) uses detection. | |||
| 111 | + | Colors terminalcap.ColorMode | |||
| 112 | + | ColorsSet bool | |||
| 113 | + | } | |||
| 114 | + | ||||
| 101 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | 115 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | ||
| 102 | // only enforces these application-selected limits. Zero megabytes disables | 116 | // only enforces these application-selected limits. Zero megabytes disables | ||
| 103 | // history; zero lines removes the additional line ceiling. | 117 | // history; zero lines removes the additional line ceiling. | ||
| @@ -139,6 +153,7 @@ type Config struct { | |||||
| 139 | Tabs TabsConfig | 153 | Tabs TabsConfig | ||
| 140 | Ephemeral EphemeralConfig | 154 | Ephemeral EphemeralConfig | ||
| 141 | Keyboard KeyboardConfig | 155 | Keyboard KeyboardConfig | ||
| 156 | + | Terminal TerminalConfig | |||
| 142 | Scrollback ScrollbackConfig | 157 | Scrollback ScrollbackConfig | ||
| 143 | } | 158 | } | ||
| 144 | 159 | ||||
M internal/domain/terminalcap/capabilities.go +102 -25
| @@ -1,7 +1,10 @@ | |||
| 1 | 1 | // Package terminalcap owns pure terminal capability values and detection policy. | |
| 2 | 2 | package terminalcap | |
| 3 | 3 | ||
| 4 | - | import "strings" | |
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 5 | 8 | ||
| 6 | 9 | // ColorMode is the color output mode selected for one attachment. | |
| 7 | 10 | type ColorMode uint8 | |
| @@ -10,9 +13,27 @@ const ( | |||
| 10 | 13 | // TrueColor is the zero value so manually constructed attachments retain the | |
| 11 | 14 | // historical renderer behavior; live Hello paths always use the detector. | |
| 12 | 15 | TrueColor ColorMode = iota | |
| 13 | - | Indexed256 | |
| 16 | + | ANSI256 | |
| 17 | + | ANSI16 | |
| 18 | + | Monochrome | |
| 14 | 19 | ) | |
| 15 | 20 | ||
| 21 | + | // Valid reports whether m is one of the defined color modes. | |
| 22 | + | func (m ColorMode) Valid() bool { return m <= Monochrome } | |
| 23 | + | ||
| 24 | + | // ColorCapabilities is the color output capability selected for one attachment | |
| 25 | + | // and how confidently it was selected. | |
| 26 | + | type ColorCapabilities struct { | |
| 27 | + | Mode ColorMode | |
| 28 | + | Source Source | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Valid reports whether both the mode and the source are defined values. | |
| 32 | + | func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() } | |
| 33 | + | ||
| 34 | + | // RGB reports whether the attachment can receive RGB ANSI output. | |
| 35 | + | func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor } | |
| 36 | + | ||
| 16 | 37 | // Source records how confidently a terminal capability was selected. | |
| 17 | 38 | type Source uint8 | |
| 18 | 39 | ||
| @@ -20,8 +41,14 @@ const ( | |||
| 20 | 41 | SourceUnknown Source = iota | |
| 21 | 42 | SourceHeuristic | |
| 22 | 43 | SourceDeclared | |
| 44 | + | // SourceForced is an explicit user override that detection must not | |
| 45 | + | // second-guess and that never warrants a downgrade notice. | |
| 46 | + | SourceForced | |
| 23 | 47 | ) | |
| 24 | 48 | ||
| 49 | + | // Valid reports whether s is one of the defined sources. | |
| 50 | + | func (s Source) Valid() bool { return s <= SourceForced } | |
| 51 | + | ||
| 25 | 52 | // Application identifies a known terminal application when its environment | |
| 26 | 53 | // provides a trustworthy origin signal. | |
| 27 | 54 | type Application uint8 | |
| @@ -33,8 +60,7 @@ const ( | |||
| 33 | 60 | ||
| 34 | 61 | // Capabilities describes the output features selected for one client attachment. | |
| 35 | 62 | type Capabilities struct { | |
| 36 | - | ColorMode ColorMode | |
| 37 | - | ColorSource Source | |
| 63 | + | Color ColorCapabilities | |
| 38 | 64 | Application Application | |
| 39 | 65 | KittyGraphics bool | |
| 40 | 66 | } | |
| @@ -43,15 +69,12 @@ type Capabilities struct { | |||
| 43 | 69 | // accepted the Kitty graphics protocol. Environment detection never sets it. | |
| 44 | 70 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | |
| 45 | 71 | ||
| 46 | - | // TrueColor reports whether this attachment can receive RGB ANSI output. | |
| 47 | - | func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor } | |
| 48 | - | ||
| 49 | 72 | // Detect derives conservative attachment capabilities from a client environment. | |
| 50 | 73 | func Detect(env []string) Capabilities { | |
| 51 | 74 | values := environmentValues(env) | |
| 52 | 75 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | |
| 53 | 76 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | |
| 54 | - | caps := Capabilities{ColorMode: Indexed256} | |
| 77 | + | caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}} | |
| 55 | 78 | ||
| 56 | 79 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | |
| 57 | 80 | caps.Application = ApplicationKitty | |
| @@ -60,29 +83,76 @@ func Detect(env []string) Capabilities { | |||
| 60 | 83 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | |
| 61 | 84 | switch colorTerm { | |
| 62 | 85 | case "truecolor", "24bit": | |
| 63 | - | caps.ColorMode = TrueColor | |
| 64 | - | caps.ColorSource = SourceDeclared | |
| 86 | + | caps.Color.Mode = TrueColor | |
| 87 | + | caps.Color.Source = SourceDeclared | |
| 65 | 88 | } | |
| 66 | 89 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | |
| 67 | - | caps.ColorMode = TrueColor | |
| 68 | - | caps.ColorSource = SourceDeclared | |
| 90 | + | caps.Color.Mode = TrueColor | |
| 91 | + | caps.Color.Source = SourceDeclared | |
| 69 | 92 | } | |
| 70 | 93 | if kittyIdentity { | |
| 71 | - | if caps.ColorSource == SourceUnknown { | |
| 72 | - | caps.ColorSource = SourceHeuristic | |
| 94 | + | if caps.Color.Source == SourceUnknown { | |
| 95 | + | caps.Color.Source = SourceHeuristic | |
| 73 | 96 | } | |
| 74 | - | caps.ColorMode = TrueColor | |
| 97 | + | caps.Color.Mode = TrueColor | |
| 75 | 98 | return caps | |
| 76 | 99 | } | |
| 77 | - | if caps.ColorSource == SourceDeclared { | |
| 100 | + | if caps.Color.Source == SourceDeclared { | |
| 78 | 101 | return caps | |
| 79 | 102 | } | |
| 80 | - | if strings.Contains(term, "256color") || term == "dumb" { | |
| 81 | - | caps.ColorSource = SourceDeclared | |
| 103 | + | if mode, ok := termColorMode(term); ok { | |
| 104 | + | caps.Color.Mode = mode | |
| 105 | + | caps.Color.Source = SourceDeclared | |
| 82 | 106 | } | |
| 83 | 107 | return caps | |
| 84 | 108 | } | |
| 85 | 109 | ||
| 110 | + | // termColorMode classifies a lowercase TERM value that carries no truecolor | |
| 111 | + | // signal. It is deliberately conservative: only well-known terminfo names map | |
| 112 | + | // to a constrained mode, and anything else reports ok=false so the caller keeps | |
| 113 | + | // its unverified 256-color default. The rules apply in this order: | |
| 114 | + | // | |
| 115 | + | // - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono; | |
| 116 | + | // - 256 colors: any name containing "256color"; | |
| 117 | + | // - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color. | |
| 118 | + | func termColorMode(term string) (ColorMode, bool) { | |
| 119 | + | switch term { | |
| 120 | + | case "dumb", "vt52", "vt100", "vt102", "vt220": | |
| 121 | + | return Monochrome, true | |
| 122 | + | case "linux", "ansi", "cons25": | |
| 123 | + | return ANSI16, true | |
| 124 | + | } | |
| 125 | + | switch { | |
| 126 | + | case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"): | |
| 127 | + | return Monochrome, true | |
| 128 | + | case strings.Contains(term, "256color"): | |
| 129 | + | return ANSI256, true | |
| 130 | + | case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"): | |
| 131 | + | return ANSI16, true | |
| 132 | + | } | |
| 133 | + | return 0, false | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // ParseColorMode parses a user color override: auto, truecolor, 256, 16, or | |
| 137 | + | // mono, case-insensitively and ignoring surrounding space. An empty value is | |
| 138 | + | // auto. When auto is true the caller must use detection and mode is | |
| 139 | + | // meaningless; an unknown value returns an error. | |
| 140 | + | func ParseColorMode(value string) (mode ColorMode, auto bool, err error) { | |
| 141 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 142 | + | case "", "auto": | |
| 143 | + | return TrueColor, true, nil | |
| 144 | + | case "truecolor": | |
| 145 | + | return TrueColor, false, nil | |
| 146 | + | case "256": | |
| 147 | + | return ANSI256, false, nil | |
| 148 | + | case "16": | |
| 149 | + | return ANSI16, false, nil | |
| 150 | + | case "mono": | |
| 151 | + | return Monochrome, false, nil | |
| 152 | + | } | |
| 153 | + | return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value) | |
| 154 | + | } | |
| 155 | + | ||
| 86 | 156 | func environmentValues(env []string) map[string]string { | |
| 87 | 157 | values := make(map[string]string, len(env)) | |
| 88 | 158 | for _, entry := range env { | |
| @@ -94,11 +164,18 @@ func environmentValues(env []string) map[string]string { | |||
| 94 | 164 | return values | |
| 95 | 165 | } | |
| 96 | 166 | ||
| 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() | |
| 167 | + | // Resolve selects attachment capabilities from the client environment and the | |
| 168 | + | // client's color claim. A declared or forced client color is used verbatim. A | |
| 169 | + | // heuristic truecolor claim upgrades a weaker daemon-side detection, because | |
| 170 | + | // the daemon may not see the client environment that produced the inference. | |
| 171 | + | // Any other claim falls back to environment detection. | |
| 172 | + | func Resolve(env []string, declared ColorCapabilities) Capabilities { | |
| 173 | + | caps := Detect(env) | |
| 174 | + | switch { | |
| 175 | + | case declared.Source == SourceDeclared || declared.Source == SourceForced: | |
| 176 | + | caps.Color = declared | |
| 177 | + | case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB(): | |
| 178 | + | caps.Color = declared | |
| 179 | + | } | |
| 180 | + | return caps | |
| 104 | 181 | } | |
| @@ -1,7 +1,10 @@ | |||||
| 1 | // Package terminalcap owns pure terminal capability values and detection policy. | 1 | // Package terminalcap owns pure terminal capability values and detection policy. | ||
| 2 | package terminalcap | 2 | package terminalcap | ||
| 3 | 3 | ||||
| 4 | - | import "strings" | 4 | + | import ( |
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 5 | 8 | ||||
| 6 | // ColorMode is the color output mode selected for one attachment. | 9 | // ColorMode is the color output mode selected for one attachment. | ||
| 7 | type ColorMode uint8 | 10 | type ColorMode uint8 | ||
| @@ -10,9 +13,27 @@ const ( | |||||
| 10 | // TrueColor is the zero value so manually constructed attachments retain the | 13 | // TrueColor is the zero value so manually constructed attachments retain the | ||
| 11 | // historical renderer behavior; live Hello paths always use the detector. | 14 | // historical renderer behavior; live Hello paths always use the detector. | ||
| 12 | TrueColor ColorMode = iota | 15 | TrueColor ColorMode = iota | ||
| 13 | - | Indexed256 | 16 | + | ANSI256 |
| 17 | + | ANSI16 | |||
| 18 | + | Monochrome | |||
| 14 | ) | 19 | ) | ||
| 15 | 20 | ||||
| 21 | + | // Valid reports whether m is one of the defined color modes. | |||
| 22 | + | func (m ColorMode) Valid() bool { return m <= Monochrome } | |||
| 23 | + | ||||
| 24 | + | // ColorCapabilities is the color output capability selected for one attachment | |||
| 25 | + | // and how confidently it was selected. | |||
| 26 | + | type ColorCapabilities struct { | |||
| 27 | + | Mode ColorMode | |||
| 28 | + | Source Source | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Valid reports whether both the mode and the source are defined values. | |||
| 32 | + | func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() } | |||
| 33 | + | ||||
| 34 | + | // RGB reports whether the attachment can receive RGB ANSI output. | |||
| 35 | + | func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor } | |||
| 36 | + | ||||
| 16 | // Source records how confidently a terminal capability was selected. | 37 | // Source records how confidently a terminal capability was selected. | ||
| 17 | type Source uint8 | 38 | type Source uint8 | ||
| 18 | 39 | ||||
| @@ -20,8 +41,14 @@ const ( | |||||
| 20 | SourceUnknown Source = iota | 41 | SourceUnknown Source = iota | ||
| 21 | SourceHeuristic | 42 | SourceHeuristic | ||
| 22 | SourceDeclared | 43 | SourceDeclared | ||
| 44 | + | // SourceForced is an explicit user override that detection must not | |||
| 45 | + | // second-guess and that never warrants a downgrade notice. | |||
| 46 | + | SourceForced | |||
| 23 | ) | 47 | ) | ||
| 24 | 48 | ||||
| 49 | + | // Valid reports whether s is one of the defined sources. | |||
| 50 | + | func (s Source) Valid() bool { return s <= SourceForced } | |||
| 51 | + | ||||
| 25 | // Application identifies a known terminal application when its environment | 52 | // Application identifies a known terminal application when its environment | ||
| 26 | // provides a trustworthy origin signal. | 53 | // provides a trustworthy origin signal. | ||
| 27 | type Application uint8 | 54 | type Application uint8 | ||
| @@ -33,8 +60,7 @@ const ( | |||||
| 33 | 60 | ||||
| 34 | // Capabilities describes the output features selected for one client attachment. | 61 | // Capabilities describes the output features selected for one client attachment. | ||
| 35 | type Capabilities struct { | 62 | type Capabilities struct { | ||
| 36 | - | ColorMode ColorMode | 63 | + | Color ColorCapabilities |
| 37 | - | ColorSource Source | |||
| 38 | Application Application | 64 | Application Application | ||
| 39 | KittyGraphics bool | 65 | KittyGraphics bool | ||
| 40 | } | 66 | } | ||
| @@ -43,15 +69,12 @@ type Capabilities struct { | |||||
| 43 | // accepted the Kitty graphics protocol. Environment detection never sets it. | 69 | // accepted the Kitty graphics protocol. Environment detection never sets it. | ||
| 44 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | 70 | func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics } | ||
| 45 | 71 | ||||
| 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. | 72 | // Detect derives conservative attachment capabilities from a client environment. | ||
| 50 | func Detect(env []string) Capabilities { | 73 | func Detect(env []string) Capabilities { | ||
| 51 | values := environmentValues(env) | 74 | values := environmentValues(env) | ||
| 52 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | 75 | term := strings.ToLower(strings.TrimSpace(values["TERM"])) | ||
| 53 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | 76 | colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"])) | ||
| 54 | - | caps := Capabilities{ColorMode: Indexed256} | 77 | + | caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}} |
| 55 | 78 | ||||
| 56 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | 79 | if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" { | ||
| 57 | caps.Application = ApplicationKitty | 80 | caps.Application = ApplicationKitty | ||
| @@ -60,29 +83,76 @@ func Detect(env []string) Capabilities { | |||||
| 60 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | 83 | kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty | ||
| 61 | switch colorTerm { | 84 | switch colorTerm { | ||
| 62 | case "truecolor", "24bit": | 85 | case "truecolor", "24bit": | ||
| 63 | - | caps.ColorMode = TrueColor | 86 | + | caps.Color.Mode = TrueColor |
| 64 | - | caps.ColorSource = SourceDeclared | 87 | + | caps.Color.Source = SourceDeclared |
| 65 | } | 88 | } | ||
| 66 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | 89 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | ||
| 67 | - | caps.ColorMode = TrueColor | 90 | + | caps.Color.Mode = TrueColor |
| 68 | - | caps.ColorSource = SourceDeclared | 91 | + | caps.Color.Source = SourceDeclared |
| 69 | } | 92 | } | ||
| 70 | if kittyIdentity { | 93 | if kittyIdentity { | ||
| 71 | - | if caps.ColorSource == SourceUnknown { | 94 | + | if caps.Color.Source == SourceUnknown { |
| 72 | - | caps.ColorSource = SourceHeuristic | 95 | + | caps.Color.Source = SourceHeuristic |
| 73 | } | 96 | } | ||
| 74 | - | caps.ColorMode = TrueColor | 97 | + | caps.Color.Mode = TrueColor |
| 75 | return caps | 98 | return caps | ||
| 76 | } | 99 | } | ||
| 77 | - | if caps.ColorSource == SourceDeclared { | 100 | + | if caps.Color.Source == SourceDeclared { |
| 78 | return caps | 101 | return caps | ||
| 79 | } | 102 | } | ||
| 80 | - | if strings.Contains(term, "256color") || term == "dumb" { | 103 | + | if mode, ok := termColorMode(term); ok { |
| 81 | - | caps.ColorSource = SourceDeclared | 104 | + | caps.Color.Mode = mode |
| 105 | + | caps.Color.Source = SourceDeclared | |||
| 82 | } | 106 | } | ||
| 83 | return caps | 107 | return caps | ||
| 84 | } | 108 | } | ||
| 85 | 109 | ||||
| 110 | + | // termColorMode classifies a lowercase TERM value that carries no truecolor | |||
| 111 | + | // signal. It is deliberately conservative: only well-known terminfo names map | |||
| 112 | + | // to a constrained mode, and anything else reports ok=false so the caller keeps | |||
| 113 | + | // its unverified 256-color default. The rules apply in this order: | |||
| 114 | + | // | |||
| 115 | + | // - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono; | |||
| 116 | + | // - 256 colors: any name containing "256color"; | |||
| 117 | + | // - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color. | |||
| 118 | + | func termColorMode(term string) (ColorMode, bool) { | |||
| 119 | + | switch term { | |||
| 120 | + | case "dumb", "vt52", "vt100", "vt102", "vt220": | |||
| 121 | + | return Monochrome, true | |||
| 122 | + | case "linux", "ansi", "cons25": | |||
| 123 | + | return ANSI16, true | |||
| 124 | + | } | |||
| 125 | + | switch { | |||
| 126 | + | case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"): | |||
| 127 | + | return Monochrome, true | |||
| 128 | + | case strings.Contains(term, "256color"): | |||
| 129 | + | return ANSI256, true | |||
| 130 | + | case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"): | |||
| 131 | + | return ANSI16, true | |||
| 132 | + | } | |||
| 133 | + | return 0, false | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // ParseColorMode parses a user color override: auto, truecolor, 256, 16, or | |||
| 137 | + | // mono, case-insensitively and ignoring surrounding space. An empty value is | |||
| 138 | + | // auto. When auto is true the caller must use detection and mode is | |||
| 139 | + | // meaningless; an unknown value returns an error. | |||
| 140 | + | func ParseColorMode(value string) (mode ColorMode, auto bool, err error) { | |||
| 141 | + | switch strings.ToLower(strings.TrimSpace(value)) { | |||
| 142 | + | case "", "auto": | |||
| 143 | + | return TrueColor, true, nil | |||
| 144 | + | case "truecolor": | |||
| 145 | + | return TrueColor, false, nil | |||
| 146 | + | case "256": | |||
| 147 | + | return ANSI256, false, nil | |||
| 148 | + | case "16": | |||
| 149 | + | return ANSI16, false, nil | |||
| 150 | + | case "mono": | |||
| 151 | + | return Monochrome, false, nil | |||
| 152 | + | } | |||
| 153 | + | return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 86 | func environmentValues(env []string) map[string]string { | 156 | func environmentValues(env []string) map[string]string { | ||
| 87 | values := make(map[string]string, len(env)) | 157 | values := make(map[string]string, len(env)) | ||
| 88 | for _, entry := range env { | 158 | for _, entry := range env { | ||
| @@ -94,11 +164,18 @@ func environmentValues(env []string) map[string]string { | |||||
| 94 | return values | 164 | return values | ||
| 95 | } | 165 | } | ||
| 96 | 166 | ||||
| 97 | - | // DetectTrueColor reports whether the supplied terminal environment advertises | 167 | + | // Resolve selects attachment capabilities from the client environment and the |
| 98 | - | // direct color support. Explicit TERM/COLORTERM values override env entries. | 168 | + | // client's color claim. A declared or forced client color is used verbatim. A |
| 99 | - | func DetectTrueColor(termEnv, colorTerm string, env []string) bool { | 169 | + | // heuristic truecolor claim upgrades a weaker daemon-side detection, because |
| 100 | - | detectionEnv := make([]string, 0, len(env)+2) | 170 | + | // the daemon may not see the client environment that produced the inference. |
| 101 | - | detectionEnv = append(detectionEnv, env...) | 171 | + | // Any other claim falls back to environment detection. |
| 102 | - | detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm) | 172 | + | func Resolve(env []string, declared ColorCapabilities) Capabilities { |
| 103 | - | return Detect(detectionEnv).TrueColor() | 173 | + | caps := Detect(env) |
| 174 | + | switch { | |||
| 175 | + | case declared.Source == SourceDeclared || declared.Source == SourceForced: | |||
| 176 | + | caps.Color = declared | |||
| 177 | + | case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB(): | |||
| 178 | + | caps.Color = declared | |||
| 179 | + | } | |||
| 180 | + | return caps | |||
| 104 | } | 181 | } | ||
M internal/domain/terminalcap/capabilities_test.go +138 -7
| @@ -6,8 +6,72 @@ 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 | + | }{ | |
| 15 | + | {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true}, | |
| 16 | + | {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true}, | |
| 17 | + | {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false}, | |
| 18 | + | {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false}, | |
| 19 | + | {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false}, | |
| 20 | + | } | |
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | require.Equal(t, tt.wantRGB, tt.in.RGB()) | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestColorCapabilitiesValid(t *testing.T) { | |
| 29 | + | tests := []struct { | |
| 30 | + | name string | |
| 31 | + | in ColorCapabilities | |
| 32 | + | want bool | |
| 33 | + | }{ | |
| 34 | + | {name: "zero value", in: ColorCapabilities{}, want: true}, | |
| 35 | + | {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true}, | |
| 36 | + | {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}}, | |
| 37 | + | {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}}, | |
| 38 | + | } | |
| 39 | + | for _, tt := range tests { | |
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |
| 41 | + | require.Equal(t, tt.want, tt.in.Valid()) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestResolve(t *testing.T) { | |
| 47 | + | tests := []struct { | |
| 48 | + | name string | |
| 49 | + | env []string | |
| 50 | + | declared ColorCapabilities | |
| 51 | + | want ColorCapabilities | |
| 52 | + | wantApp Application | |
| 53 | + | }{ | |
| 54 | + | {name: "unknown claim falls back to detection", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |
| 55 | + | {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |
| 56 | + | {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 57 | + | {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 58 | + | {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |
| 59 | + | {name: "declared 256 overrides detected truecolor verbatim", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |
| 60 | + | {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}}, | |
| 61 | + | {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}}, | |
| 62 | + | {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}}, | |
| 63 | + | {name: "heuristic truecolor keeps stronger detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty}, | |
| 64 | + | {name: "heuristic 16 colors falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceHeuristic}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |
| 65 | + | {name: "declared claim keeps detected application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty}, | |
| 66 | + | } | |
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | got := Resolve(tt.env, tt.declared) | |
| 70 | + | require.Equal(t, tt.want, got.Color) | |
| 71 | + | require.Equal(t, tt.wantApp, got.Application) | |
| 72 | + | require.False(t, got.KittyGraphics) | |
| 73 | + | }) | |
| 74 | + | } | |
| 11 | 75 | } | |
| 12 | 76 | ||
| 13 | 77 | func TestDetect(t *testing.T) { | |
| @@ -24,18 +88,85 @@ func TestDetect(t *testing.T) { | |||
| 24 | 88 | {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |
| 25 | 89 | {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | |
| 26 | 90 | {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}, | |
| 91 | + | {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |
| 92 | + | {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 93 | + | {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 94 | + | {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 95 | + | {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 96 | + | {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |
| 97 | + | {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |
| 98 | + | {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 99 | + | {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 100 | + | {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 101 | + | {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 102 | + | {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 103 | + | {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 104 | + | {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 105 | + | {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |
| 106 | + | {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |
| 107 | + | {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 108 | + | {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 109 | + | {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 110 | + | {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 111 | + | {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 112 | + | {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 113 | + | {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |
| 114 | + | {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |
| 115 | + | {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |
| 116 | + | {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |
| 117 | + | {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |
| 118 | + | {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | |
| 119 | + | {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 | 120 | } | |
| 31 | 121 | ||
| 32 | 122 | for _, tt := range tests { | |
| 33 | 123 | t.Run(tt.name, func(t *testing.T) { | |
| 34 | 124 | got := Detect(tt.env) | |
| 35 | - | require.Equal(t, tt.wantMode, got.ColorMode) | |
| 36 | - | require.Equal(t, tt.wantSource, got.ColorSource) | |
| 125 | + | require.Equal(t, tt.wantMode, got.Color.Mode) | |
| 126 | + | require.Equal(t, tt.wantSource, got.Color.Source) | |
| 37 | 127 | require.Equal(t, tt.wantApp, got.Application) | |
| 38 | 128 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | |
| 39 | 129 | }) | |
| 40 | 130 | } | |
| 41 | 131 | } | |
| 132 | + | ||
| 133 | + | func TestParseColorMode(t *testing.T) { | |
| 134 | + | tests := []struct { | |
| 135 | + | name string | |
| 136 | + | in string | |
| 137 | + | wantMode ColorMode | |
| 138 | + | wantAuto bool | |
| 139 | + | wantErr bool | |
| 140 | + | }{ | |
| 141 | + | {name: "empty is auto", in: "", wantAuto: true}, | |
| 142 | + | {name: "blank is auto", in: " \t", wantAuto: true}, | |
| 143 | + | {name: "auto", in: "auto", wantAuto: true}, | |
| 144 | + | {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true}, | |
| 145 | + | {name: "truecolor", in: "truecolor", wantMode: TrueColor}, | |
| 146 | + | {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor}, | |
| 147 | + | {name: "256", in: "256", wantMode: ANSI256}, | |
| 148 | + | {name: "16", in: " 16 ", wantMode: ANSI16}, | |
| 149 | + | {name: "mono", in: "mono", wantMode: Monochrome}, | |
| 150 | + | {name: "mono upper case", in: "MONO", wantMode: Monochrome}, | |
| 151 | + | {name: "24bit is not accepted", in: "24bit", wantErr: true}, | |
| 152 | + | {name: "256color is not accepted", in: "256color", wantErr: true}, | |
| 153 | + | {name: "number out of set", in: "8", wantErr: true}, | |
| 154 | + | {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true}, | |
| 155 | + | {name: "garbage", in: "rainbow", wantErr: true}, | |
| 156 | + | } | |
| 157 | + | for _, tt := range tests { | |
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |
| 159 | + | mode, auto, err := ParseColorMode(tt.in) | |
| 160 | + | if tt.wantErr { | |
| 161 | + | require.Error(t, err) | |
| 162 | + | require.False(t, auto) | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, tt.wantAuto, auto) | |
| 167 | + | if !auto { | |
| 168 | + | require.Equal(t, tt.wantMode, mode) | |
| 169 | + | } | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
| @@ -6,8 +6,72 @@ 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 | + | }{ | |||
| 15 | + | {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true}, | |||
| 16 | + | {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true}, | |||
| 17 | + | {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false}, | |||
| 18 | + | {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false}, | |||
| 19 | + | {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | require.Equal(t, tt.wantRGB, tt.in.RGB()) | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestColorCapabilitiesValid(t *testing.T) { | |||
| 29 | + | tests := []struct { | |||
| 30 | + | name string | |||
| 31 | + | in ColorCapabilities | |||
| 32 | + | want bool | |||
| 33 | + | }{ | |||
| 34 | + | {name: "zero value", in: ColorCapabilities{}, want: true}, | |||
| 35 | + | {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true}, | |||
| 36 | + | {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}}, | |||
| 37 | + | {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}}, | |||
| 38 | + | } | |||
| 39 | + | for _, tt := range tests { | |||
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 41 | + | require.Equal(t, tt.want, tt.in.Valid()) | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestResolve(t *testing.T) { | |||
| 47 | + | tests := []struct { | |||
| 48 | + | name string | |||
| 49 | + | env []string | |||
| 50 | + | declared ColorCapabilities | |||
| 51 | + | want ColorCapabilities | |||
| 52 | + | wantApp Application | |||
| 53 | + | }{ | |||
| 54 | + | {name: "unknown claim falls back to detection", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |||
| 55 | + | {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |||
| 56 | + | {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 57 | + | {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 58 | + | {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}}, | |||
| 59 | + | {name: "declared 256 overrides detected truecolor verbatim", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |||
| 60 | + | {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}}, | |||
| 61 | + | {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}}, | |||
| 62 | + | {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}}, | |||
| 63 | + | {name: "heuristic truecolor keeps stronger detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty}, | |||
| 64 | + | {name: "heuristic 16 colors falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceHeuristic}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}}, | |||
| 65 | + | {name: "declared claim keeps detected application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty}, | |||
| 66 | + | } | |||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | got := Resolve(tt.env, tt.declared) | |||
| 70 | + | require.Equal(t, tt.want, got.Color) | |||
| 71 | + | require.Equal(t, tt.wantApp, got.Application) | |||
| 72 | + | require.False(t, got.KittyGraphics) | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 11 | } | 75 | } | ||
| 12 | 76 | ||||
| 13 | func TestDetect(t *testing.T) { | 77 | func TestDetect(t *testing.T) { | ||
| @@ -24,18 +88,85 @@ func TestDetect(t *testing.T) { | |||||
| 24 | {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | 88 | {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}, | 89 | {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}, | 90 | {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}, | 91 | + | {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}, | 92 | + | {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}, | 93 | + | {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown}, |
| 94 | + | {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |||
| 95 | + | {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |||
| 96 | + | {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |||
| 97 | + | {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |||
| 98 | + | {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 99 | + | {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 100 | + | {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 101 | + | {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 102 | + | {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 103 | + | {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 104 | + | {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 105 | + | {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared}, | |||
| 106 | + | {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown}, | |||
| 107 | + | {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 108 | + | {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 109 | + | {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 110 | + | {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 111 | + | {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 112 | + | {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 113 | + | {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared}, | |||
| 114 | + | {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |||
| 115 | + | {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |||
| 116 | + | {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared}, | |||
| 117 | + | {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared}, | |||
| 118 | + | {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty}, | |||
| 119 | + | {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 | } | 120 | } | ||
| 31 | 121 | ||||
| 32 | for _, tt := range tests { | 122 | for _, tt := range tests { | ||
| 33 | t.Run(tt.name, func(t *testing.T) { | 123 | t.Run(tt.name, func(t *testing.T) { | ||
| 34 | got := Detect(tt.env) | 124 | got := Detect(tt.env) | ||
| 35 | - | require.Equal(t, tt.wantMode, got.ColorMode) | 125 | + | require.Equal(t, tt.wantMode, got.Color.Mode) |
| 36 | - | require.Equal(t, tt.wantSource, got.ColorSource) | 126 | + | require.Equal(t, tt.wantSource, got.Color.Source) |
| 37 | require.Equal(t, tt.wantApp, got.Application) | 127 | require.Equal(t, tt.wantApp, got.Application) | ||
| 38 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | 128 | require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics()) | ||
| 39 | }) | 129 | }) | ||
| 40 | } | 130 | } | ||
| 41 | } | 131 | } | ||
| 132 | + | ||||
| 133 | + | func TestParseColorMode(t *testing.T) { | |||
| 134 | + | tests := []struct { | |||
| 135 | + | name string | |||
| 136 | + | in string | |||
| 137 | + | wantMode ColorMode | |||
| 138 | + | wantAuto bool | |||
| 139 | + | wantErr bool | |||
| 140 | + | }{ | |||
| 141 | + | {name: "empty is auto", in: "", wantAuto: true}, | |||
| 142 | + | {name: "blank is auto", in: " \t", wantAuto: true}, | |||
| 143 | + | {name: "auto", in: "auto", wantAuto: true}, | |||
| 144 | + | {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true}, | |||
| 145 | + | {name: "truecolor", in: "truecolor", wantMode: TrueColor}, | |||
| 146 | + | {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor}, | |||
| 147 | + | {name: "256", in: "256", wantMode: ANSI256}, | |||
| 148 | + | {name: "16", in: " 16 ", wantMode: ANSI16}, | |||
| 149 | + | {name: "mono", in: "mono", wantMode: Monochrome}, | |||
| 150 | + | {name: "mono upper case", in: "MONO", wantMode: Monochrome}, | |||
| 151 | + | {name: "24bit is not accepted", in: "24bit", wantErr: true}, | |||
| 152 | + | {name: "256color is not accepted", in: "256color", wantErr: true}, | |||
| 153 | + | {name: "number out of set", in: "8", wantErr: true}, | |||
| 154 | + | {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true}, | |||
| 155 | + | {name: "garbage", in: "rainbow", wantErr: true}, | |||
| 156 | + | } | |||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | mode, auto, err := ParseColorMode(tt.in) | |||
| 160 | + | if tt.wantErr { | |||
| 161 | + | require.Error(t, err) | |||
| 162 | + | require.False(t, auto) | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.Equal(t, tt.wantAuto, auto) | |||
| 167 | + | if !auto { | |||
| 168 | + | require.Equal(t, tt.wantMode, mode) | |||
| 169 | + | } | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | } | |||
M internal/protocol/session.go +6 -4
| @@ -10,13 +10,14 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | renderer "github.com/bnema/vev-vt" | |
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // Version is the negotiated vev session protocol version. It carries the | |
| 16 | 17 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 18 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 19 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 64 | |
| 20 | + | const Version uint16 = 65 | |
| 20 | 21 | ||
| 21 | 22 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 23 | // first committed publication. It excludes the preceding client-local | |
| @@ -110,7 +111,9 @@ type Hello struct { | |||
| 110 | 111 | PixelHeight int | |
| 111 | 112 | TermEnv string | |
| 112 | 113 | Cwd string | |
| 113 | - | TrueColor bool | |
| 114 | + | // Color is the color output capability the client claims for this | |
| 115 | + | // attachment. It travels only in Hello; Theme carries no capability. | |
| 116 | + | Color terminalcap.ColorCapabilities | |
| 114 | 117 | // KittyDirectGraphics is an explicit declaration that the client has | |
| 115 | 118 | // probed its direct outer terminal and it accepts Kitty graphics output. | |
| 116 | 119 | KittyDirectGraphics bool | |
| @@ -158,7 +161,6 @@ type Theme struct { | |||
| 158 | 161 | Foreground renderer.RGB | |
| 159 | 162 | HasBackground bool | |
| 160 | 163 | Background renderer.RGB | |
| 161 | - | TrueColor bool | |
| 162 | 164 | SchemeKnown bool | |
| 163 | 165 | Light bool | |
| 164 | 166 | PaletteKnown uint16 | |
| @@ -436,7 +438,7 @@ func ValidateHello(h Hello) error { | |||
| 436 | 438 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | |
| 437 | 439 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | |
| 438 | 440 | } | |
| 439 | - | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) { | |
| 441 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) { | |
| 440 | 442 | return ErrInvalidHello | |
| 441 | 443 | } | |
| 442 | 444 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | |
| @@ -10,13 +10,14 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | renderer "github.com/bnema/vev-vt" | 11 | renderer "github.com/bnema/vev-vt" | ||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // Version is the negotiated vev session protocol version. It carries the | 16 | // Version is the negotiated vev session protocol version. It carries the | ||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 17 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 18 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 19 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 64 | 20 | + | const Version uint16 = 65 |
| 20 | 21 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 22 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 23 | // first committed publication. It excludes the preceding client-local | ||
| @@ -110,7 +111,9 @@ type Hello struct { | |||||
| 110 | PixelHeight int | 111 | PixelHeight int | ||
| 111 | TermEnv string | 112 | TermEnv string | ||
| 112 | Cwd string | 113 | Cwd string | ||
| 113 | - | TrueColor bool | 114 | + | // Color is the color output capability the client claims for this |
| 115 | + | // attachment. It travels only in Hello; Theme carries no capability. | |||
| 116 | + | Color terminalcap.ColorCapabilities | |||
| 114 | // KittyDirectGraphics is an explicit declaration that the client has | 117 | // KittyDirectGraphics is an explicit declaration that the client has | ||
| 115 | // probed its direct outer terminal and it accepts Kitty graphics output. | 118 | // probed its direct outer terminal and it accepts Kitty graphics output. | ||
| 116 | KittyDirectGraphics bool | 119 | KittyDirectGraphics bool | ||
| @@ -158,7 +161,6 @@ type Theme struct { | |||||
| 158 | Foreground renderer.RGB | 161 | Foreground renderer.RGB | ||
| 159 | HasBackground bool | 162 | HasBackground bool | ||
| 160 | Background renderer.RGB | 163 | Background renderer.RGB | ||
| 161 | - | TrueColor bool | |||
| 162 | SchemeKnown bool | 164 | SchemeKnown bool | ||
| 163 | Light bool | 165 | Light bool | ||
| 164 | PaletteKnown uint16 | 166 | PaletteKnown uint16 | ||
| @@ -436,7 +438,7 @@ func ValidateHello(h Hello) error { | |||||
| 436 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | 438 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | ||
| 437 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | 439 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | ||
| 438 | } | 440 | } | ||
| 439 | - | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) { | 441 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) { |
| 440 | return ErrInvalidHello | 442 | return ErrInvalidHello | ||
| 441 | } | 443 | } | ||
| 442 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | 444 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(64), Version) | |
| 13 | + | require.Equal(t, uint16(65), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(64), Version) | 13 | + | require.Equal(t, uint16(65), Version) |
| 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/baseline_bench_test.go +3 -2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "google.golang.org/protobuf/proto" | |
| 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/protocol" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -24,7 +25,7 @@ func baselineHello() protocol.Hello { | |||
| 24 | 25 | Size: domain.Size{Cols: 120, Rows: 40}, | |
| 25 | 26 | TermEnv: "xterm-256color", | |
| 26 | 27 | Cwd: "/tmp/project", | |
| 27 | - | TrueColor: true, | |
| 28 | + | Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, | |
| 28 | 29 | MaxOutputInFlight: 8, | |
| 29 | 30 | } | |
| 30 | 31 | } | |
| @@ -48,7 +49,7 @@ func BenchmarkHelloEnvelope(b *testing.B) { | |||
| 48 | 49 | Rows: uint32(hello.Size.Rows), | |
| 49 | 50 | TermEnv: hello.TermEnv, | |
| 50 | 51 | Cwd: hello.Cwd, | |
| 51 | - | TrueColor: hello.TrueColor, | |
| 52 | + | Color: &ColorCapabilities{Mode: ColorMode_COLOR_MODE_ANSI256, Source: ColorSource_COLOR_SOURCE_DECLARED}, | |
| 52 | 53 | MaxOutputInFlight: uint32(hello.MaxOutputInFlight), | |
| 53 | 54 | } | |
| 54 | 55 | b.ReportAllocs() | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "google.golang.org/protobuf/proto" | 7 | "google.golang.org/protobuf/proto" | ||
| 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/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -24,7 +25,7 @@ func baselineHello() protocol.Hello { | |||||
| 24 | Size: domain.Size{Cols: 120, Rows: 40}, | 25 | Size: domain.Size{Cols: 120, Rows: 40}, | ||
| 25 | TermEnv: "xterm-256color", | 26 | TermEnv: "xterm-256color", | ||
| 26 | Cwd: "/tmp/project", | 27 | Cwd: "/tmp/project", | ||
| 27 | - | TrueColor: true, | 28 | + | Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, |
| 28 | MaxOutputInFlight: 8, | 29 | MaxOutputInFlight: 8, | ||
| 29 | } | 30 | } | ||
| 30 | } | 31 | } | ||
| @@ -48,7 +49,7 @@ func BenchmarkHelloEnvelope(b *testing.B) { | |||||
| 48 | Rows: uint32(hello.Size.Rows), | 49 | Rows: uint32(hello.Size.Rows), | ||
| 49 | TermEnv: hello.TermEnv, | 50 | TermEnv: hello.TermEnv, | ||
| 50 | Cwd: hello.Cwd, | 51 | Cwd: hello.Cwd, | ||
| 51 | - | TrueColor: hello.TrueColor, | 52 | + | Color: &ColorCapabilities{Mode: ColorMode_COLOR_MODE_ANSI256, Source: ColorSource_COLOR_SOURCE_DECLARED}, |
| 52 | MaxOutputInFlight: uint32(hello.MaxOutputInFlight), | 53 | MaxOutputInFlight: uint32(hello.MaxOutputInFlight), | ||
| 53 | } | 54 | } | ||
| 54 | b.ReportAllocs() | 55 | b.ReportAllocs() | ||
M internal/protocol/wire/common.pb.go +222 -36
| @@ -28,6 +28,117 @@ const ( | |||
| 28 | 28 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | + | // ColorMode is the closed color output mode of one attachment | |
| 32 | + | // (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire. | |
| 33 | + | type ColorMode int32 | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | ColorMode_COLOR_MODE_UNSPECIFIED ColorMode = 0 | |
| 37 | + | ColorMode_COLOR_MODE_TRUE_COLOR ColorMode = 1 | |
| 38 | + | ColorMode_COLOR_MODE_ANSI256 ColorMode = 2 | |
| 39 | + | ColorMode_COLOR_MODE_ANSI16 ColorMode = 3 | |
| 40 | + | ColorMode_COLOR_MODE_MONOCHROME ColorMode = 4 | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | // Enum value maps for ColorMode. | |
| 44 | + | var ( | |
| 45 | + | ColorMode_name = map[int32]string{ | |
| 46 | + | 0: "COLOR_MODE_UNSPECIFIED", | |
| 47 | + | 1: "COLOR_MODE_TRUE_COLOR", | |
| 48 | + | 2: "COLOR_MODE_ANSI256", | |
| 49 | + | 3: "COLOR_MODE_ANSI16", | |
| 50 | + | 4: "COLOR_MODE_MONOCHROME", | |
| 51 | + | } | |
| 52 | + | ColorMode_value = map[string]int32{ | |
| 53 | + | "COLOR_MODE_UNSPECIFIED": 0, | |
| 54 | + | "COLOR_MODE_TRUE_COLOR": 1, | |
| 55 | + | "COLOR_MODE_ANSI256": 2, | |
| 56 | + | "COLOR_MODE_ANSI16": 3, | |
| 57 | + | "COLOR_MODE_MONOCHROME": 4, | |
| 58 | + | } | |
| 59 | + | ) | |
| 60 | + | ||
| 61 | + | func (x ColorMode) Enum() *ColorMode { | |
| 62 | + | p := new(ColorMode) | |
| 63 | + | *p = x | |
| 64 | + | return p | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (x ColorMode) String() string { | |
| 68 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (ColorMode) Descriptor() protoreflect.EnumDescriptor { | |
| 72 | + | return file_common_proto_enumTypes[0].Descriptor() | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (ColorMode) Type() protoreflect.EnumType { | |
| 76 | + | return &file_common_proto_enumTypes[0] | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (x ColorMode) Number() protoreflect.EnumNumber { | |
| 80 | + | return protoreflect.EnumNumber(x) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Deprecated: Use ColorMode.Descriptor instead. | |
| 84 | + | func (ColorMode) EnumDescriptor() ([]byte, []int) { | |
| 85 | + | return file_common_proto_rawDescGZIP(), []int{0} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // ColorSource records how confidently a color mode was selected | |
| 89 | + | // (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source. | |
| 90 | + | type ColorSource int32 | |
| 91 | + | ||
| 92 | + | const ( | |
| 93 | + | ColorSource_COLOR_SOURCE_UNSPECIFIED ColorSource = 0 | |
| 94 | + | ColorSource_COLOR_SOURCE_HEURISTIC ColorSource = 1 | |
| 95 | + | ColorSource_COLOR_SOURCE_DECLARED ColorSource = 2 | |
| 96 | + | ColorSource_COLOR_SOURCE_FORCED ColorSource = 3 | |
| 97 | + | ) | |
| 98 | + | ||
| 99 | + | // Enum value maps for ColorSource. | |
| 100 | + | var ( | |
| 101 | + | ColorSource_name = map[int32]string{ | |
| 102 | + | 0: "COLOR_SOURCE_UNSPECIFIED", | |
| 103 | + | 1: "COLOR_SOURCE_HEURISTIC", | |
| 104 | + | 2: "COLOR_SOURCE_DECLARED", | |
| 105 | + | 3: "COLOR_SOURCE_FORCED", | |
| 106 | + | } | |
| 107 | + | ColorSource_value = map[string]int32{ | |
| 108 | + | "COLOR_SOURCE_UNSPECIFIED": 0, | |
| 109 | + | "COLOR_SOURCE_HEURISTIC": 1, | |
| 110 | + | "COLOR_SOURCE_DECLARED": 2, | |
| 111 | + | "COLOR_SOURCE_FORCED": 3, | |
| 112 | + | } | |
| 113 | + | ) | |
| 114 | + | ||
| 115 | + | func (x ColorSource) Enum() *ColorSource { | |
| 116 | + | p := new(ColorSource) | |
| 117 | + | *p = x | |
| 118 | + | return p | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (x ColorSource) String() string { | |
| 122 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (ColorSource) Descriptor() protoreflect.EnumDescriptor { | |
| 126 | + | return file_common_proto_enumTypes[1].Descriptor() | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (ColorSource) Type() protoreflect.EnumType { | |
| 130 | + | return &file_common_proto_enumTypes[1] | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (x ColorSource) Number() protoreflect.EnumNumber { | |
| 134 | + | return protoreflect.EnumNumber(x) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Deprecated: Use ColorSource.Descriptor instead. | |
| 138 | + | func (ColorSource) EnumDescriptor() ([]byte, []int) { | |
| 139 | + | return file_common_proto_rawDescGZIP(), []int{1} | |
| 140 | + | } | |
| 141 | + | ||
| 31 | 142 | // LifecycleID is the opaque 16-byte identity of one session lifecycle | |
| 32 | 143 | // (domain.SessionLifecycleID / domain.IncarnationID). | |
| 33 | 144 | type LifecycleID struct { | |
| @@ -463,6 +574,60 @@ func (x *RGB) GetB() uint32 { | |||
| 463 | 574 | return 0 | |
| 464 | 575 | } | |
| 465 | 576 | ||
| 577 | + | // ColorCapabilities is the color output capability a client claims for one | |
| 578 | + | // attachment (terminalcap.ColorCapabilities). | |
| 579 | + | type ColorCapabilities struct { | |
| 580 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 581 | + | Mode ColorMode `protobuf:"varint,1,opt,name=mode,proto3,enum=vev.wire.v1.ColorMode" json:"mode,omitempty"` | |
| 582 | + | Source ColorSource `protobuf:"varint,2,opt,name=source,proto3,enum=vev.wire.v1.ColorSource" json:"source,omitempty"` | |
| 583 | + | unknownFields protoimpl.UnknownFields | |
| 584 | + | sizeCache protoimpl.SizeCache | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func (x *ColorCapabilities) Reset() { | |
| 588 | + | *x = ColorCapabilities{} | |
| 589 | + | mi := &file_common_proto_msgTypes[6] | |
| 590 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 591 | + | ms.StoreMessageInfo(mi) | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func (x *ColorCapabilities) String() string { | |
| 595 | + | return protoimpl.X.MessageStringOf(x) | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func (*ColorCapabilities) ProtoMessage() {} | |
| 599 | + | ||
| 600 | + | func (x *ColorCapabilities) ProtoReflect() protoreflect.Message { | |
| 601 | + | mi := &file_common_proto_msgTypes[6] | |
| 602 | + | if x != nil { | |
| 603 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 604 | + | if ms.LoadMessageInfo() == nil { | |
| 605 | + | ms.StoreMessageInfo(mi) | |
| 606 | + | } | |
| 607 | + | return ms | |
| 608 | + | } | |
| 609 | + | return mi.MessageOf(x) | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // Deprecated: Use ColorCapabilities.ProtoReflect.Descriptor instead. | |
| 613 | + | func (*ColorCapabilities) Descriptor() ([]byte, []int) { | |
| 614 | + | return file_common_proto_rawDescGZIP(), []int{6} | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func (x *ColorCapabilities) GetMode() ColorMode { | |
| 618 | + | if x != nil { | |
| 619 | + | return x.Mode | |
| 620 | + | } | |
| 621 | + | return ColorMode_COLOR_MODE_UNSPECIFIED | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | func (x *ColorCapabilities) GetSource() ColorSource { | |
| 625 | + | if x != nil { | |
| 626 | + | return x.Source | |
| 627 | + | } | |
| 628 | + | return ColorSource_COLOR_SOURCE_UNSPECIFIED | |
| 629 | + | } | |
| 630 | + | ||
| 466 | 631 | // CellStyle is the bounded style block of one preview cell | |
| 467 | 632 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | |
| 468 | 633 | type CellStyle struct { | |
| @@ -488,7 +653,7 @@ type CellStyle struct { | |||
| 488 | 653 | ||
| 489 | 654 | func (x *CellStyle) Reset() { | |
| 490 | 655 | *x = CellStyle{} | |
| 491 | - | mi := &file_common_proto_msgTypes[6] | |
| 656 | + | mi := &file_common_proto_msgTypes[7] | |
| 492 | 657 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 493 | 658 | ms.StoreMessageInfo(mi) | |
| 494 | 659 | } | |
| @@ -500,7 +665,7 @@ func (x *CellStyle) String() string { | |||
| 500 | 665 | func (*CellStyle) ProtoMessage() {} | |
| 501 | 666 | ||
| 502 | 667 | func (x *CellStyle) ProtoReflect() protoreflect.Message { | |
| 503 | - | mi := &file_common_proto_msgTypes[6] | |
| 668 | + | mi := &file_common_proto_msgTypes[7] | |
| 504 | 669 | if x != nil { | |
| 505 | 670 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 506 | 671 | if ms.LoadMessageInfo() == nil { | |
| @@ -513,7 +678,7 @@ func (x *CellStyle) ProtoReflect() protoreflect.Message { | |||
| 513 | 678 | ||
| 514 | 679 | // Deprecated: Use CellStyle.ProtoReflect.Descriptor instead. | |
| 515 | 680 | func (*CellStyle) Descriptor() ([]byte, []int) { | |
| 516 | - | return file_common_proto_rawDescGZIP(), []int{6} | |
| 681 | + | return file_common_proto_rawDescGZIP(), []int{7} | |
| 517 | 682 | } | |
| 518 | 683 | ||
| 519 | 684 | func (x *CellStyle) GetBold() bool { | |
| @@ -634,7 +799,7 @@ type PreviewCell struct { | |||
| 634 | 799 | ||
| 635 | 800 | func (x *PreviewCell) Reset() { | |
| 636 | 801 | *x = PreviewCell{} | |
| 637 | - | mi := &file_common_proto_msgTypes[7] | |
| 802 | + | mi := &file_common_proto_msgTypes[8] | |
| 638 | 803 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 639 | 804 | ms.StoreMessageInfo(mi) | |
| 640 | 805 | } | |
| @@ -646,7 +811,7 @@ func (x *PreviewCell) String() string { | |||
| 646 | 811 | func (*PreviewCell) ProtoMessage() {} | |
| 647 | 812 | ||
| 648 | 813 | func (x *PreviewCell) ProtoReflect() protoreflect.Message { | |
| 649 | - | mi := &file_common_proto_msgTypes[7] | |
| 814 | + | mi := &file_common_proto_msgTypes[8] | |
| 650 | 815 | if x != nil { | |
| 651 | 816 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 652 | 817 | if ms.LoadMessageInfo() == nil { | |
| @@ -659,7 +824,7 @@ func (x *PreviewCell) ProtoReflect() protoreflect.Message { | |||
| 659 | 824 | ||
| 660 | 825 | // Deprecated: Use PreviewCell.ProtoReflect.Descriptor instead. | |
| 661 | 826 | func (*PreviewCell) Descriptor() ([]byte, []int) { | |
| 662 | - | return file_common_proto_rawDescGZIP(), []int{7} | |
| 827 | + | return file_common_proto_rawDescGZIP(), []int{8} | |
| 663 | 828 | } | |
| 664 | 829 | ||
| 665 | 830 | func (x *PreviewCell) GetRuneValue() uint32 { | |
| @@ -696,7 +861,7 @@ type RemoteRegistration struct { | |||
| 696 | 861 | ||
| 697 | 862 | func (x *RemoteRegistration) Reset() { | |
| 698 | 863 | *x = RemoteRegistration{} | |
| 699 | - | mi := &file_common_proto_msgTypes[8] | |
| 864 | + | mi := &file_common_proto_msgTypes[9] | |
| 700 | 865 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 701 | 866 | ms.StoreMessageInfo(mi) | |
| 702 | 867 | } | |
| @@ -708,7 +873,7 @@ func (x *RemoteRegistration) String() string { | |||
| 708 | 873 | func (*RemoteRegistration) ProtoMessage() {} | |
| 709 | 874 | ||
| 710 | 875 | func (x *RemoteRegistration) ProtoReflect() protoreflect.Message { | |
| 711 | - | mi := &file_common_proto_msgTypes[8] | |
| 876 | + | mi := &file_common_proto_msgTypes[9] | |
| 712 | 877 | if x != nil { | |
| 713 | 878 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 714 | 879 | if ms.LoadMessageInfo() == nil { | |
| @@ -721,7 +886,7 @@ func (x *RemoteRegistration) ProtoReflect() protoreflect.Message { | |||
| 721 | 886 | ||
| 722 | 887 | // Deprecated: Use RemoteRegistration.ProtoReflect.Descriptor instead. | |
| 723 | 888 | func (*RemoteRegistration) Descriptor() ([]byte, []int) { | |
| 724 | - | return file_common_proto_rawDescGZIP(), []int{8} | |
| 889 | + | return file_common_proto_rawDescGZIP(), []int{9} | |
| 725 | 890 | } | |
| 726 | 891 | ||
| 727 | 892 | func (x *RemoteRegistration) GetEndpoint() string { | |
| @@ -786,7 +951,10 @@ const file_common_proto_rawDesc = "" + | |||
| 786 | 951 | "\x03RGB\x12\f\n" + | |
| 787 | 952 | "\x01r\x18\x01 \x01(\rR\x01r\x12\f\n" + | |
| 788 | 953 | "\x01g\x18\x02 \x01(\rR\x01g\x12\f\n" + | |
| 789 | - | "\x01b\x18\x03 \x01(\rR\x01b\"\xf0\x04\n" + | |
| 954 | + | "\x01b\x18\x03 \x01(\rR\x01b\"q\n" + | |
| 955 | + | "\x11ColorCapabilities\x12*\n" + | |
| 956 | + | "\x04mode\x18\x01 \x01(\x0e2\x16.vev.wire.v1.ColorModeR\x04mode\x120\n" + | |
| 957 | + | "\x06source\x18\x02 \x01(\x0e2\x18.vev.wire.v1.ColorSourceR\x06source\"\xf0\x04\n" + | |
| 790 | 958 | "\tCellStyle\x12\x12\n" + | |
| 791 | 959 | "\x04bold\x18\x01 \x01(\bR\x04bold\x12\x16\n" + | |
| 792 | 960 | "\x06italic\x18\x02 \x01(\bR\x06italic\x12\x18\n" + | |
| @@ -818,7 +986,18 @@ const file_common_proto_rawDesc = "" + | |||
| 818 | 986 | "\vincarnation\x18\x02 \x01(\fR\vincarnation\x12\x1e\n" + | |
| 819 | 987 | "\n" + | |
| 820 | 988 | "generation\x18\x03 \x01(\x04R\n" + | |
| 821 | - | "generationB-Z+github.com/bnema/vev/internal/protocol/wireb\x06proto3" | |
| 989 | + | "generation*\x8c\x01\n" + | |
| 990 | + | "\tColorMode\x12\x1a\n" + | |
| 991 | + | "\x16COLOR_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + | |
| 992 | + | "\x15COLOR_MODE_TRUE_COLOR\x10\x01\x12\x16\n" + | |
| 993 | + | "\x12COLOR_MODE_ANSI256\x10\x02\x12\x15\n" + | |
| 994 | + | "\x11COLOR_MODE_ANSI16\x10\x03\x12\x19\n" + | |
| 995 | + | "\x15COLOR_MODE_MONOCHROME\x10\x04*{\n" + | |
| 996 | + | "\vColorSource\x12\x1c\n" + | |
| 997 | + | "\x18COLOR_SOURCE_UNSPECIFIED\x10\x00\x12\x1a\n" + | |
| 998 | + | "\x16COLOR_SOURCE_HEURISTIC\x10\x01\x12\x19\n" + | |
| 999 | + | "\x15COLOR_SOURCE_DECLARED\x10\x02\x12\x17\n" + | |
| 1000 | + | "\x13COLOR_SOURCE_FORCED\x10\x03B-Z+github.com/bnema/vev/internal/protocol/wireb\x06proto3" | |
| 822 | 1001 | ||
| 823 | 1002 | var ( | |
| 824 | 1003 | file_common_proto_rawDescOnce sync.Once | |
| @@ -832,32 +1011,38 @@ func file_common_proto_rawDescGZIP() []byte { | |||
| 832 | 1011 | return file_common_proto_rawDescData | |
| 833 | 1012 | } | |
| 834 | 1013 | ||
| 835 | - | var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 9) | |
| 1014 | + | var file_common_proto_enumTypes = make([]protoimpl.EnumInfo, 2) | |
| 1015 | + | var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 10) | |
| 836 | 1016 | var file_common_proto_goTypes = []any{ | |
| 837 | - | (*LifecycleID)(nil), // 0: vev.wire.v1.LifecycleID | |
| 838 | - | (*TabSelector)(nil), // 1: vev.wire.v1.TabSelector | |
| 839 | - | (*RemoteTarget)(nil), // 2: vev.wire.v1.RemoteTarget | |
| 840 | - | (*ExactTarget)(nil), // 3: vev.wire.v1.ExactTarget | |
| 841 | - | (*SessionAttachTarget)(nil), // 4: vev.wire.v1.SessionAttachTarget | |
| 842 | - | (*RGB)(nil), // 5: vev.wire.v1.RGB | |
| 843 | - | (*CellStyle)(nil), // 6: vev.wire.v1.CellStyle | |
| 844 | - | (*PreviewCell)(nil), // 7: vev.wire.v1.PreviewCell | |
| 845 | - | (*RemoteRegistration)(nil), // 8: vev.wire.v1.RemoteRegistration | |
| 1017 | + | (ColorMode)(0), // 0: vev.wire.v1.ColorMode | |
| 1018 | + | (ColorSource)(0), // 1: vev.wire.v1.ColorSource | |
| 1019 | + | (*LifecycleID)(nil), // 2: vev.wire.v1.LifecycleID | |
| 1020 | + | (*TabSelector)(nil), // 3: vev.wire.v1.TabSelector | |
| 1021 | + | (*RemoteTarget)(nil), // 4: vev.wire.v1.RemoteTarget | |
| 1022 | + | (*ExactTarget)(nil), // 5: vev.wire.v1.ExactTarget | |
| 1023 | + | (*SessionAttachTarget)(nil), // 6: vev.wire.v1.SessionAttachTarget | |
| 1024 | + | (*RGB)(nil), // 7: vev.wire.v1.RGB | |
| 1025 | + | (*ColorCapabilities)(nil), // 8: vev.wire.v1.ColorCapabilities | |
| 1026 | + | (*CellStyle)(nil), // 9: vev.wire.v1.CellStyle | |
| 1027 | + | (*PreviewCell)(nil), // 10: vev.wire.v1.PreviewCell | |
| 1028 | + | (*RemoteRegistration)(nil), // 11: vev.wire.v1.RemoteRegistration | |
| 846 | 1029 | } | |
| 847 | 1030 | var file_common_proto_depIdxs = []int32{ | |
| 848 | - | 0, // 0: vev.wire.v1.RemoteTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 849 | - | 1, // 1: vev.wire.v1.RemoteTarget.stopped_tab:type_name -> vev.wire.v1.TabSelector | |
| 850 | - | 0, // 2: vev.wire.v1.ExactTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 851 | - | 0, // 3: vev.wire.v1.SessionAttachTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 852 | - | 5, // 4: vev.wire.v1.CellStyle.foreground_rgb:type_name -> vev.wire.v1.RGB | |
| 853 | - | 5, // 5: vev.wire.v1.CellStyle.background_rgb:type_name -> vev.wire.v1.RGB | |
| 854 | - | 5, // 6: vev.wire.v1.CellStyle.underline_color_rgb:type_name -> vev.wire.v1.RGB | |
| 855 | - | 6, // 7: vev.wire.v1.PreviewCell.style:type_name -> vev.wire.v1.CellStyle | |
| 856 | - | 8, // [8:8] is the sub-list for method output_type | |
| 857 | - | 8, // [8:8] is the sub-list for method input_type | |
| 858 | - | 8, // [8:8] is the sub-list for extension type_name | |
| 859 | - | 8, // [8:8] is the sub-list for extension extendee | |
| 860 | - | 0, // [0:8] is the sub-list for field type_name | |
| 1031 | + | 2, // 0: vev.wire.v1.RemoteTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 1032 | + | 3, // 1: vev.wire.v1.RemoteTarget.stopped_tab:type_name -> vev.wire.v1.TabSelector | |
| 1033 | + | 2, // 2: vev.wire.v1.ExactTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 1034 | + | 2, // 3: vev.wire.v1.SessionAttachTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | |
| 1035 | + | 0, // 4: vev.wire.v1.ColorCapabilities.mode:type_name -> vev.wire.v1.ColorMode | |
| 1036 | + | 1, // 5: vev.wire.v1.ColorCapabilities.source:type_name -> vev.wire.v1.ColorSource | |
| 1037 | + | 7, // 6: vev.wire.v1.CellStyle.foreground_rgb:type_name -> vev.wire.v1.RGB | |
| 1038 | + | 7, // 7: vev.wire.v1.CellStyle.background_rgb:type_name -> vev.wire.v1.RGB | |
| 1039 | + | 7, // 8: vev.wire.v1.CellStyle.underline_color_rgb:type_name -> vev.wire.v1.RGB | |
| 1040 | + | 9, // 9: vev.wire.v1.PreviewCell.style:type_name -> vev.wire.v1.CellStyle | |
| 1041 | + | 10, // [10:10] is the sub-list for method output_type | |
| 1042 | + | 10, // [10:10] is the sub-list for method input_type | |
| 1043 | + | 10, // [10:10] is the sub-list for extension type_name | |
| 1044 | + | 10, // [10:10] is the sub-list for extension extendee | |
| 1045 | + | 0, // [0:10] is the sub-list for field type_name | |
| 861 | 1046 | } | |
| 862 | 1047 | ||
| 863 | 1048 | func init() { file_common_proto_init() } | |
| @@ -871,13 +1056,14 @@ func file_common_proto_init() { | |||
| 871 | 1056 | File: protoimpl.DescBuilder{ | |
| 872 | 1057 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 873 | 1058 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), | |
| 874 | - | NumEnums: 0, | |
| 875 | - | NumMessages: 9, | |
| 1059 | + | NumEnums: 2, | |
| 1060 | + | NumMessages: 10, | |
| 876 | 1061 | NumExtensions: 0, | |
| 877 | 1062 | NumServices: 0, | |
| 878 | 1063 | }, | |
| 879 | 1064 | GoTypes: file_common_proto_goTypes, | |
| 880 | 1065 | DependencyIndexes: file_common_proto_depIdxs, | |
| 1066 | + | EnumInfos: file_common_proto_enumTypes, | |
| 881 | 1067 | MessageInfos: file_common_proto_msgTypes, | |
| 882 | 1068 | }.Build() | |
| 883 | 1069 | File_common_proto = out.File | |
| @@ -28,6 +28,117 @@ const ( | |||||
| 28 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | 28 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | ||
| 29 | ) | 29 | ) | ||
| 30 | 30 | ||||
| 31 | + | // ColorMode is the closed color output mode of one attachment | |||
| 32 | + | // (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire. | |||
| 33 | + | type ColorMode int32 | |||
| 34 | + | ||||
| 35 | + | const ( | |||
| 36 | + | ColorMode_COLOR_MODE_UNSPECIFIED ColorMode = 0 | |||
| 37 | + | ColorMode_COLOR_MODE_TRUE_COLOR ColorMode = 1 | |||
| 38 | + | ColorMode_COLOR_MODE_ANSI256 ColorMode = 2 | |||
| 39 | + | ColorMode_COLOR_MODE_ANSI16 ColorMode = 3 | |||
| 40 | + | ColorMode_COLOR_MODE_MONOCHROME ColorMode = 4 | |||
| 41 | + | ) | |||
| 42 | + | ||||
| 43 | + | // Enum value maps for ColorMode. | |||
| 44 | + | var ( | |||
| 45 | + | ColorMode_name = map[int32]string{ | |||
| 46 | + | 0: "COLOR_MODE_UNSPECIFIED", | |||
| 47 | + | 1: "COLOR_MODE_TRUE_COLOR", | |||
| 48 | + | 2: "COLOR_MODE_ANSI256", | |||
| 49 | + | 3: "COLOR_MODE_ANSI16", | |||
| 50 | + | 4: "COLOR_MODE_MONOCHROME", | |||
| 51 | + | } | |||
| 52 | + | ColorMode_value = map[string]int32{ | |||
| 53 | + | "COLOR_MODE_UNSPECIFIED": 0, | |||
| 54 | + | "COLOR_MODE_TRUE_COLOR": 1, | |||
| 55 | + | "COLOR_MODE_ANSI256": 2, | |||
| 56 | + | "COLOR_MODE_ANSI16": 3, | |||
| 57 | + | "COLOR_MODE_MONOCHROME": 4, | |||
| 58 | + | } | |||
| 59 | + | ) | |||
| 60 | + | ||||
| 61 | + | func (x ColorMode) Enum() *ColorMode { | |||
| 62 | + | p := new(ColorMode) | |||
| 63 | + | *p = x | |||
| 64 | + | return p | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (x ColorMode) String() string { | |||
| 68 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (ColorMode) Descriptor() protoreflect.EnumDescriptor { | |||
| 72 | + | return file_common_proto_enumTypes[0].Descriptor() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (ColorMode) Type() protoreflect.EnumType { | |||
| 76 | + | return &file_common_proto_enumTypes[0] | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (x ColorMode) Number() protoreflect.EnumNumber { | |||
| 80 | + | return protoreflect.EnumNumber(x) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Deprecated: Use ColorMode.Descriptor instead. | |||
| 84 | + | func (ColorMode) EnumDescriptor() ([]byte, []int) { | |||
| 85 | + | return file_common_proto_rawDescGZIP(), []int{0} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // ColorSource records how confidently a color mode was selected | |||
| 89 | + | // (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source. | |||
| 90 | + | type ColorSource int32 | |||
| 91 | + | ||||
| 92 | + | const ( | |||
| 93 | + | ColorSource_COLOR_SOURCE_UNSPECIFIED ColorSource = 0 | |||
| 94 | + | ColorSource_COLOR_SOURCE_HEURISTIC ColorSource = 1 | |||
| 95 | + | ColorSource_COLOR_SOURCE_DECLARED ColorSource = 2 | |||
| 96 | + | ColorSource_COLOR_SOURCE_FORCED ColorSource = 3 | |||
| 97 | + | ) | |||
| 98 | + | ||||
| 99 | + | // Enum value maps for ColorSource. | |||
| 100 | + | var ( | |||
| 101 | + | ColorSource_name = map[int32]string{ | |||
| 102 | + | 0: "COLOR_SOURCE_UNSPECIFIED", | |||
| 103 | + | 1: "COLOR_SOURCE_HEURISTIC", | |||
| 104 | + | 2: "COLOR_SOURCE_DECLARED", | |||
| 105 | + | 3: "COLOR_SOURCE_FORCED", | |||
| 106 | + | } | |||
| 107 | + | ColorSource_value = map[string]int32{ | |||
| 108 | + | "COLOR_SOURCE_UNSPECIFIED": 0, | |||
| 109 | + | "COLOR_SOURCE_HEURISTIC": 1, | |||
| 110 | + | "COLOR_SOURCE_DECLARED": 2, | |||
| 111 | + | "COLOR_SOURCE_FORCED": 3, | |||
| 112 | + | } | |||
| 113 | + | ) | |||
| 114 | + | ||||
| 115 | + | func (x ColorSource) Enum() *ColorSource { | |||
| 116 | + | p := new(ColorSource) | |||
| 117 | + | *p = x | |||
| 118 | + | return p | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (x ColorSource) String() string { | |||
| 122 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (ColorSource) Descriptor() protoreflect.EnumDescriptor { | |||
| 126 | + | return file_common_proto_enumTypes[1].Descriptor() | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (ColorSource) Type() protoreflect.EnumType { | |||
| 130 | + | return &file_common_proto_enumTypes[1] | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (x ColorSource) Number() protoreflect.EnumNumber { | |||
| 134 | + | return protoreflect.EnumNumber(x) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Deprecated: Use ColorSource.Descriptor instead. | |||
| 138 | + | func (ColorSource) EnumDescriptor() ([]byte, []int) { | |||
| 139 | + | return file_common_proto_rawDescGZIP(), []int{1} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 31 | // LifecycleID is the opaque 16-byte identity of one session lifecycle | 142 | // LifecycleID is the opaque 16-byte identity of one session lifecycle | ||
| 32 | // (domain.SessionLifecycleID / domain.IncarnationID). | 143 | // (domain.SessionLifecycleID / domain.IncarnationID). | ||
| 33 | type LifecycleID struct { | 144 | type LifecycleID struct { | ||
| @@ -463,6 +574,60 @@ func (x *RGB) GetB() uint32 { | |||||
| 463 | return 0 | 574 | return 0 | ||
| 464 | } | 575 | } | ||
| 465 | 576 | ||||
| 577 | + | // ColorCapabilities is the color output capability a client claims for one | |||
| 578 | + | // attachment (terminalcap.ColorCapabilities). | |||
| 579 | + | type ColorCapabilities struct { | |||
| 580 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 581 | + | Mode ColorMode `protobuf:"varint,1,opt,name=mode,proto3,enum=vev.wire.v1.ColorMode" json:"mode,omitempty"` | |||
| 582 | + | Source ColorSource `protobuf:"varint,2,opt,name=source,proto3,enum=vev.wire.v1.ColorSource" json:"source,omitempty"` | |||
| 583 | + | unknownFields protoimpl.UnknownFields | |||
| 584 | + | sizeCache protoimpl.SizeCache | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | func (x *ColorCapabilities) Reset() { | |||
| 588 | + | *x = ColorCapabilities{} | |||
| 589 | + | mi := &file_common_proto_msgTypes[6] | |||
| 590 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 591 | + | ms.StoreMessageInfo(mi) | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | func (x *ColorCapabilities) String() string { | |||
| 595 | + | return protoimpl.X.MessageStringOf(x) | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func (*ColorCapabilities) ProtoMessage() {} | |||
| 599 | + | ||||
| 600 | + | func (x *ColorCapabilities) ProtoReflect() protoreflect.Message { | |||
| 601 | + | mi := &file_common_proto_msgTypes[6] | |||
| 602 | + | if x != nil { | |||
| 603 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 604 | + | if ms.LoadMessageInfo() == nil { | |||
| 605 | + | ms.StoreMessageInfo(mi) | |||
| 606 | + | } | |||
| 607 | + | return ms | |||
| 608 | + | } | |||
| 609 | + | return mi.MessageOf(x) | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | // Deprecated: Use ColorCapabilities.ProtoReflect.Descriptor instead. | |||
| 613 | + | func (*ColorCapabilities) Descriptor() ([]byte, []int) { | |||
| 614 | + | return file_common_proto_rawDescGZIP(), []int{6} | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func (x *ColorCapabilities) GetMode() ColorMode { | |||
| 618 | + | if x != nil { | |||
| 619 | + | return x.Mode | |||
| 620 | + | } | |||
| 621 | + | return ColorMode_COLOR_MODE_UNSPECIFIED | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | func (x *ColorCapabilities) GetSource() ColorSource { | |||
| 625 | + | if x != nil { | |||
| 626 | + | return x.Source | |||
| 627 | + | } | |||
| 628 | + | return ColorSource_COLOR_SOURCE_UNSPECIFIED | |||
| 629 | + | } | |||
| 630 | + | ||||
| 466 | // CellStyle is the bounded style block of one preview cell | 631 | // CellStyle is the bounded style block of one preview cell | ||
| 467 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | 632 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | ||
| 468 | type CellStyle struct { | 633 | type CellStyle struct { | ||
| @@ -488,7 +653,7 @@ type CellStyle struct { | |||||
| 488 | 653 | ||||
| 489 | func (x *CellStyle) Reset() { | 654 | func (x *CellStyle) Reset() { | ||
| 490 | *x = CellStyle{} | 655 | *x = CellStyle{} | ||
| 491 | - | mi := &file_common_proto_msgTypes[6] | 656 | + | mi := &file_common_proto_msgTypes[7] |
| 492 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 657 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 493 | ms.StoreMessageInfo(mi) | 658 | ms.StoreMessageInfo(mi) | ||
| 494 | } | 659 | } | ||
| @@ -500,7 +665,7 @@ func (x *CellStyle) String() string { | |||||
| 500 | func (*CellStyle) ProtoMessage() {} | 665 | func (*CellStyle) ProtoMessage() {} | ||
| 501 | 666 | ||||
| 502 | func (x *CellStyle) ProtoReflect() protoreflect.Message { | 667 | func (x *CellStyle) ProtoReflect() protoreflect.Message { | ||
| 503 | - | mi := &file_common_proto_msgTypes[6] | 668 | + | mi := &file_common_proto_msgTypes[7] |
| 504 | if x != nil { | 669 | if x != nil { | ||
| 505 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 670 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 506 | if ms.LoadMessageInfo() == nil { | 671 | if ms.LoadMessageInfo() == nil { | ||
| @@ -513,7 +678,7 @@ func (x *CellStyle) ProtoReflect() protoreflect.Message { | |||||
| 513 | 678 | ||||
| 514 | // Deprecated: Use CellStyle.ProtoReflect.Descriptor instead. | 679 | // Deprecated: Use CellStyle.ProtoReflect.Descriptor instead. | ||
| 515 | func (*CellStyle) Descriptor() ([]byte, []int) { | 680 | func (*CellStyle) Descriptor() ([]byte, []int) { | ||
| 516 | - | return file_common_proto_rawDescGZIP(), []int{6} | 681 | + | return file_common_proto_rawDescGZIP(), []int{7} |
| 517 | } | 682 | } | ||
| 518 | 683 | ||||
| 519 | func (x *CellStyle) GetBold() bool { | 684 | func (x *CellStyle) GetBold() bool { | ||
| @@ -634,7 +799,7 @@ type PreviewCell struct { | |||||
| 634 | 799 | ||||
| 635 | func (x *PreviewCell) Reset() { | 800 | func (x *PreviewCell) Reset() { | ||
| 636 | *x = PreviewCell{} | 801 | *x = PreviewCell{} | ||
| 637 | - | mi := &file_common_proto_msgTypes[7] | 802 | + | mi := &file_common_proto_msgTypes[8] |
| 638 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 803 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 639 | ms.StoreMessageInfo(mi) | 804 | ms.StoreMessageInfo(mi) | ||
| 640 | } | 805 | } | ||
| @@ -646,7 +811,7 @@ func (x *PreviewCell) String() string { | |||||
| 646 | func (*PreviewCell) ProtoMessage() {} | 811 | func (*PreviewCell) ProtoMessage() {} | ||
| 647 | 812 | ||||
| 648 | func (x *PreviewCell) ProtoReflect() protoreflect.Message { | 813 | func (x *PreviewCell) ProtoReflect() protoreflect.Message { | ||
| 649 | - | mi := &file_common_proto_msgTypes[7] | 814 | + | mi := &file_common_proto_msgTypes[8] |
| 650 | if x != nil { | 815 | if x != nil { | ||
| 651 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 816 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 652 | if ms.LoadMessageInfo() == nil { | 817 | if ms.LoadMessageInfo() == nil { | ||
| @@ -659,7 +824,7 @@ func (x *PreviewCell) ProtoReflect() protoreflect.Message { | |||||
| 659 | 824 | ||||
| 660 | // Deprecated: Use PreviewCell.ProtoReflect.Descriptor instead. | 825 | // Deprecated: Use PreviewCell.ProtoReflect.Descriptor instead. | ||
| 661 | func (*PreviewCell) Descriptor() ([]byte, []int) { | 826 | func (*PreviewCell) Descriptor() ([]byte, []int) { | ||
| 662 | - | return file_common_proto_rawDescGZIP(), []int{7} | 827 | + | return file_common_proto_rawDescGZIP(), []int{8} |
| 663 | } | 828 | } | ||
| 664 | 829 | ||||
| 665 | func (x *PreviewCell) GetRuneValue() uint32 { | 830 | func (x *PreviewCell) GetRuneValue() uint32 { | ||
| @@ -696,7 +861,7 @@ type RemoteRegistration struct { | |||||
| 696 | 861 | ||||
| 697 | func (x *RemoteRegistration) Reset() { | 862 | func (x *RemoteRegistration) Reset() { | ||
| 698 | *x = RemoteRegistration{} | 863 | *x = RemoteRegistration{} | ||
| 699 | - | mi := &file_common_proto_msgTypes[8] | 864 | + | mi := &file_common_proto_msgTypes[9] |
| 700 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 865 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 701 | ms.StoreMessageInfo(mi) | 866 | ms.StoreMessageInfo(mi) | ||
| 702 | } | 867 | } | ||
| @@ -708,7 +873,7 @@ func (x *RemoteRegistration) String() string { | |||||
| 708 | func (*RemoteRegistration) ProtoMessage() {} | 873 | func (*RemoteRegistration) ProtoMessage() {} | ||
| 709 | 874 | ||||
| 710 | func (x *RemoteRegistration) ProtoReflect() protoreflect.Message { | 875 | func (x *RemoteRegistration) ProtoReflect() protoreflect.Message { | ||
| 711 | - | mi := &file_common_proto_msgTypes[8] | 876 | + | mi := &file_common_proto_msgTypes[9] |
| 712 | if x != nil { | 877 | if x != nil { | ||
| 713 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 878 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 714 | if ms.LoadMessageInfo() == nil { | 879 | if ms.LoadMessageInfo() == nil { | ||
| @@ -721,7 +886,7 @@ func (x *RemoteRegistration) ProtoReflect() protoreflect.Message { | |||||
| 721 | 886 | ||||
| 722 | // Deprecated: Use RemoteRegistration.ProtoReflect.Descriptor instead. | 887 | // Deprecated: Use RemoteRegistration.ProtoReflect.Descriptor instead. | ||
| 723 | func (*RemoteRegistration) Descriptor() ([]byte, []int) { | 888 | func (*RemoteRegistration) Descriptor() ([]byte, []int) { | ||
| 724 | - | return file_common_proto_rawDescGZIP(), []int{8} | 889 | + | return file_common_proto_rawDescGZIP(), []int{9} |
| 725 | } | 890 | } | ||
| 726 | 891 | ||||
| 727 | func (x *RemoteRegistration) GetEndpoint() string { | 892 | func (x *RemoteRegistration) GetEndpoint() string { | ||
| @@ -786,7 +951,10 @@ const file_common_proto_rawDesc = "" + | |||||
| 786 | "\x03RGB\x12\f\n" + | 951 | "\x03RGB\x12\f\n" + | ||
| 787 | "\x01r\x18\x01 \x01(\rR\x01r\x12\f\n" + | 952 | "\x01r\x18\x01 \x01(\rR\x01r\x12\f\n" + | ||
| 788 | "\x01g\x18\x02 \x01(\rR\x01g\x12\f\n" + | 953 | "\x01g\x18\x02 \x01(\rR\x01g\x12\f\n" + | ||
| 789 | - | "\x01b\x18\x03 \x01(\rR\x01b\"\xf0\x04\n" + | 954 | + | "\x01b\x18\x03 \x01(\rR\x01b\"q\n" + |
| 955 | + | "\x11ColorCapabilities\x12*\n" + | |||
| 956 | + | "\x04mode\x18\x01 \x01(\x0e2\x16.vev.wire.v1.ColorModeR\x04mode\x120\n" + | |||
| 957 | + | "\x06source\x18\x02 \x01(\x0e2\x18.vev.wire.v1.ColorSourceR\x06source\"\xf0\x04\n" + | |||
| 790 | "\tCellStyle\x12\x12\n" + | 958 | "\tCellStyle\x12\x12\n" + | ||
| 791 | "\x04bold\x18\x01 \x01(\bR\x04bold\x12\x16\n" + | 959 | "\x04bold\x18\x01 \x01(\bR\x04bold\x12\x16\n" + | ||
| 792 | "\x06italic\x18\x02 \x01(\bR\x06italic\x12\x18\n" + | 960 | "\x06italic\x18\x02 \x01(\bR\x06italic\x12\x18\n" + | ||
| @@ -818,7 +986,18 @@ const file_common_proto_rawDesc = "" + | |||||
| 818 | "\vincarnation\x18\x02 \x01(\fR\vincarnation\x12\x1e\n" + | 986 | "\vincarnation\x18\x02 \x01(\fR\vincarnation\x12\x1e\n" + | ||
| 819 | "\n" + | 987 | "\n" + | ||
| 820 | "generation\x18\x03 \x01(\x04R\n" + | 988 | "generation\x18\x03 \x01(\x04R\n" + | ||
| 821 | - | "generationB-Z+github.com/bnema/vev/internal/protocol/wireb\x06proto3" | 989 | + | "generation*\x8c\x01\n" + |
| 990 | + | "\tColorMode\x12\x1a\n" + | |||
| 991 | + | "\x16COLOR_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + | |||
| 992 | + | "\x15COLOR_MODE_TRUE_COLOR\x10\x01\x12\x16\n" + | |||
| 993 | + | "\x12COLOR_MODE_ANSI256\x10\x02\x12\x15\n" + | |||
| 994 | + | "\x11COLOR_MODE_ANSI16\x10\x03\x12\x19\n" + | |||
| 995 | + | "\x15COLOR_MODE_MONOCHROME\x10\x04*{\n" + | |||
| 996 | + | "\vColorSource\x12\x1c\n" + | |||
| 997 | + | "\x18COLOR_SOURCE_UNSPECIFIED\x10\x00\x12\x1a\n" + | |||
| 998 | + | "\x16COLOR_SOURCE_HEURISTIC\x10\x01\x12\x19\n" + | |||
| 999 | + | "\x15COLOR_SOURCE_DECLARED\x10\x02\x12\x17\n" + | |||
| 1000 | + | "\x13COLOR_SOURCE_FORCED\x10\x03B-Z+github.com/bnema/vev/internal/protocol/wireb\x06proto3" | |||
| 822 | 1001 | ||||
| 823 | var ( | 1002 | var ( | ||
| 824 | file_common_proto_rawDescOnce sync.Once | 1003 | file_common_proto_rawDescOnce sync.Once | ||
| @@ -832,32 +1011,38 @@ func file_common_proto_rawDescGZIP() []byte { | |||||
| 832 | return file_common_proto_rawDescData | 1011 | return file_common_proto_rawDescData | ||
| 833 | } | 1012 | } | ||
| 834 | 1013 | ||||
| 835 | - | var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 9) | 1014 | + | var file_common_proto_enumTypes = make([]protoimpl.EnumInfo, 2) |
| 1015 | + | var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 10) | |||
| 836 | var file_common_proto_goTypes = []any{ | 1016 | var file_common_proto_goTypes = []any{ | ||
| 837 | - | (*LifecycleID)(nil), // 0: vev.wire.v1.LifecycleID | 1017 | + | (ColorMode)(0), // 0: vev.wire.v1.ColorMode |
| 838 | - | (*TabSelector)(nil), // 1: vev.wire.v1.TabSelector | 1018 | + | (ColorSource)(0), // 1: vev.wire.v1.ColorSource |
| 839 | - | (*RemoteTarget)(nil), // 2: vev.wire.v1.RemoteTarget | 1019 | + | (*LifecycleID)(nil), // 2: vev.wire.v1.LifecycleID |
| 840 | - | (*ExactTarget)(nil), // 3: vev.wire.v1.ExactTarget | 1020 | + | (*TabSelector)(nil), // 3: vev.wire.v1.TabSelector |
| 841 | - | (*SessionAttachTarget)(nil), // 4: vev.wire.v1.SessionAttachTarget | 1021 | + | (*RemoteTarget)(nil), // 4: vev.wire.v1.RemoteTarget |
| 842 | - | (*RGB)(nil), // 5: vev.wire.v1.RGB | 1022 | + | (*ExactTarget)(nil), // 5: vev.wire.v1.ExactTarget |
| 843 | - | (*CellStyle)(nil), // 6: vev.wire.v1.CellStyle | 1023 | + | (*SessionAttachTarget)(nil), // 6: vev.wire.v1.SessionAttachTarget |
| 844 | - | (*PreviewCell)(nil), // 7: vev.wire.v1.PreviewCell | 1024 | + | (*RGB)(nil), // 7: vev.wire.v1.RGB |
| 845 | - | (*RemoteRegistration)(nil), // 8: vev.wire.v1.RemoteRegistration | 1025 | + | (*ColorCapabilities)(nil), // 8: vev.wire.v1.ColorCapabilities |
| 1026 | + | (*CellStyle)(nil), // 9: vev.wire.v1.CellStyle | |||
| 1027 | + | (*PreviewCell)(nil), // 10: vev.wire.v1.PreviewCell | |||
| 1028 | + | (*RemoteRegistration)(nil), // 11: vev.wire.v1.RemoteRegistration | |||
| 846 | } | 1029 | } | ||
| 847 | var file_common_proto_depIdxs = []int32{ | 1030 | var file_common_proto_depIdxs = []int32{ | ||
| 848 | - | 0, // 0: vev.wire.v1.RemoteTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | 1031 | + | 2, // 0: vev.wire.v1.RemoteTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID |
| 849 | - | 1, // 1: vev.wire.v1.RemoteTarget.stopped_tab:type_name -> vev.wire.v1.TabSelector | 1032 | + | 3, // 1: vev.wire.v1.RemoteTarget.stopped_tab:type_name -> vev.wire.v1.TabSelector |
| 850 | - | 0, // 2: vev.wire.v1.ExactTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | 1033 | + | 2, // 2: vev.wire.v1.ExactTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID |
| 851 | - | 0, // 3: vev.wire.v1.SessionAttachTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID | 1034 | + | 2, // 3: vev.wire.v1.SessionAttachTarget.lifecycle_id:type_name -> vev.wire.v1.LifecycleID |
| 852 | - | 5, // 4: vev.wire.v1.CellStyle.foreground_rgb:type_name -> vev.wire.v1.RGB | 1035 | + | 0, // 4: vev.wire.v1.ColorCapabilities.mode:type_name -> vev.wire.v1.ColorMode |
| 853 | - | 5, // 5: vev.wire.v1.CellStyle.background_rgb:type_name -> vev.wire.v1.RGB | 1036 | + | 1, // 5: vev.wire.v1.ColorCapabilities.source:type_name -> vev.wire.v1.ColorSource |
| 854 | - | 5, // 6: vev.wire.v1.CellStyle.underline_color_rgb:type_name -> vev.wire.v1.RGB | 1037 | + | 7, // 6: vev.wire.v1.CellStyle.foreground_rgb:type_name -> vev.wire.v1.RGB |
| 855 | - | 6, // 7: vev.wire.v1.PreviewCell.style:type_name -> vev.wire.v1.CellStyle | 1038 | + | 7, // 7: vev.wire.v1.CellStyle.background_rgb:type_name -> vev.wire.v1.RGB |
| 856 | - | 8, // [8:8] is the sub-list for method output_type | 1039 | + | 7, // 8: vev.wire.v1.CellStyle.underline_color_rgb:type_name -> vev.wire.v1.RGB |
| 857 | - | 8, // [8:8] is the sub-list for method input_type | 1040 | + | 9, // 9: vev.wire.v1.PreviewCell.style:type_name -> vev.wire.v1.CellStyle |
| 858 | - | 8, // [8:8] is the sub-list for extension type_name | 1041 | + | 10, // [10:10] is the sub-list for method output_type |
| 859 | - | 8, // [8:8] is the sub-list for extension extendee | 1042 | + | 10, // [10:10] is the sub-list for method input_type |
| 860 | - | 0, // [0:8] is the sub-list for field type_name | 1043 | + | 10, // [10:10] is the sub-list for extension type_name |
| 1044 | + | 10, // [10:10] is the sub-list for extension extendee | |||
| 1045 | + | 0, // [0:10] is the sub-list for field type_name | |||
| 861 | } | 1046 | } | ||
| 862 | 1047 | ||||
| 863 | func init() { file_common_proto_init() } | 1048 | func init() { file_common_proto_init() } | ||
| @@ -871,13 +1056,14 @@ func file_common_proto_init() { | |||||
| 871 | File: protoimpl.DescBuilder{ | 1056 | File: protoimpl.DescBuilder{ | ||
| 872 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | 1057 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | ||
| 873 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), | 1058 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), | ||
| 874 | - | NumEnums: 0, | 1059 | + | NumEnums: 2, |
| 875 | - | NumMessages: 9, | 1060 | + | NumMessages: 10, |
| 876 | NumExtensions: 0, | 1061 | NumExtensions: 0, | ||
| 877 | NumServices: 0, | 1062 | NumServices: 0, | ||
| 878 | }, | 1063 | }, | ||
| 879 | GoTypes: file_common_proto_goTypes, | 1064 | GoTypes: file_common_proto_goTypes, | ||
| 880 | DependencyIndexes: file_common_proto_depIdxs, | 1065 | DependencyIndexes: file_common_proto_depIdxs, | ||
| 1066 | + | EnumInfos: file_common_proto_enumTypes, | |||
| 881 | MessageInfos: file_common_proto_msgTypes, | 1067 | MessageInfos: file_common_proto_msgTypes, | ||
| 882 | }.Build() | 1068 | }.Build() | ||
| 883 | File_common_proto = out.File | 1069 | File_common_proto = out.File | ||
M internal/protocol/wire/envelope_inventory_test.go +2 -2
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |
| 18 | - | if protocol.Version != 64 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 64", protocol.Version) | |
| 18 | + | if protocol.Version != 65 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 65", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // shares a payload type except route navigation failure, and the preamble | ||
| 16 | // constants agree with the schema authority. | 16 | // constants agree with the schema authority. | ||
| 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | ||
| 18 | - | if protocol.Version != 64 { | 18 | + | if protocol.Version != 65 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 64", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 65", protocol.Version) |
| 20 | } | 20 | } | ||
| 21 | if ProtocolEpoch != 1 { | 21 | if ProtocolEpoch != 1 { | ||
| 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | ||
M internal/protocol/wire/schema/common.proto +26 -0
| @@ -65,6 +65,32 @@ message RGB { | |||
| 65 | 65 | uint32 b = 3; | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | // ColorMode is the closed color output mode of one attachment | |
| 69 | + | // (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire. | |
| 70 | + | enum ColorMode { | |
| 71 | + | COLOR_MODE_UNSPECIFIED = 0; | |
| 72 | + | COLOR_MODE_TRUE_COLOR = 1; | |
| 73 | + | COLOR_MODE_ANSI256 = 2; | |
| 74 | + | COLOR_MODE_ANSI16 = 3; | |
| 75 | + | COLOR_MODE_MONOCHROME = 4; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ColorSource records how confidently a color mode was selected | |
| 79 | + | // (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source. | |
| 80 | + | enum ColorSource { | |
| 81 | + | COLOR_SOURCE_UNSPECIFIED = 0; | |
| 82 | + | COLOR_SOURCE_HEURISTIC = 1; | |
| 83 | + | COLOR_SOURCE_DECLARED = 2; | |
| 84 | + | COLOR_SOURCE_FORCED = 3; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // ColorCapabilities is the color output capability a client claims for one | |
| 88 | + | // attachment (terminalcap.ColorCapabilities). | |
| 89 | + | message ColorCapabilities { | |
| 90 | + | ColorMode mode = 1; | |
| 91 | + | ColorSource source = 2; | |
| 92 | + | } | |
| 93 | + | ||
| 68 | 94 | // CellStyle is the bounded style block of one preview cell | |
| 69 | 95 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | |
| 70 | 96 | message CellStyle { | |
| @@ -65,6 +65,32 @@ message RGB { | |||||
| 65 | uint32 b = 3; | 65 | uint32 b = 3; | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | // ColorMode is the closed color output mode of one attachment | |||
| 69 | + | // (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire. | |||
| 70 | + | enum ColorMode { | |||
| 71 | + | COLOR_MODE_UNSPECIFIED = 0; | |||
| 72 | + | COLOR_MODE_TRUE_COLOR = 1; | |||
| 73 | + | COLOR_MODE_ANSI256 = 2; | |||
| 74 | + | COLOR_MODE_ANSI16 = 3; | |||
| 75 | + | COLOR_MODE_MONOCHROME = 4; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // ColorSource records how confidently a color mode was selected | |||
| 79 | + | // (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source. | |||
| 80 | + | enum ColorSource { | |||
| 81 | + | COLOR_SOURCE_UNSPECIFIED = 0; | |||
| 82 | + | COLOR_SOURCE_HEURISTIC = 1; | |||
| 83 | + | COLOR_SOURCE_DECLARED = 2; | |||
| 84 | + | COLOR_SOURCE_FORCED = 3; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // ColorCapabilities is the color output capability a client claims for one | |||
| 88 | + | // attachment (terminalcap.ColorCapabilities). | |||
| 89 | + | message ColorCapabilities { | |||
| 90 | + | ColorMode mode = 1; | |||
| 91 | + | ColorSource source = 2; | |||
| 92 | + | } | |||
| 93 | + | ||||
| 68 | // CellStyle is the bounded style block of one preview cell | 94 | // CellStyle is the bounded style block of one preview cell | ||
| 69 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | 95 | // (renderer.Style). Indexed colors use int32 so -1 (unset) survives. | ||
| 70 | message CellStyle { | 96 | message CellStyle { | ||
M internal/protocol/wire/schema/session.proto +5 -1
| @@ -21,7 +21,8 @@ message Hello { | |||
| 21 | 21 | uint32 pixel_height = 9; | |
| 22 | 22 | string term_env = 10; | |
| 23 | 23 | string cwd = 11; | |
| 24 | - | bool true_color = 12; | |
| 24 | + | reserved 12; | |
| 25 | + | reserved "true_color"; | |
| 25 | 26 | uint32 max_output_in_flight = 13; | |
| 26 | 27 | repeated string env = 14; | |
| 27 | 28 | reserved 15; | |
| @@ -39,6 +40,9 @@ message Hello { | |||
| 39 | 40 | uint32 terminal_focus = 24; | |
| 40 | 41 | // PID of a local client process, for bar scripts. 0 when unknown or remote. | |
| 41 | 42 | uint32 client_pid = 25; | |
| 43 | + | // Color output capability the client claims. Required: a missing message | |
| 44 | + | // or an UNSPECIFIED mode is a decode error. | |
| 45 | + | ColorCapabilities color = 26; | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 44 | 48 | // Welcome answers an accepted attach. | |
| @@ -21,7 +21,8 @@ message Hello { | |||||
| 21 | uint32 pixel_height = 9; | 21 | uint32 pixel_height = 9; | ||
| 22 | string term_env = 10; | 22 | string term_env = 10; | ||
| 23 | string cwd = 11; | 23 | string cwd = 11; | ||
| 24 | - | bool true_color = 12; | 24 | + | reserved 12; |
| 25 | + | reserved "true_color"; | |||
| 25 | uint32 max_output_in_flight = 13; | 26 | uint32 max_output_in_flight = 13; | ||
| 26 | repeated string env = 14; | 27 | repeated string env = 14; | ||
| 27 | reserved 15; | 28 | reserved 15; | ||
| @@ -39,6 +40,9 @@ message Hello { | |||||
| 39 | uint32 terminal_focus = 24; | 40 | uint32 terminal_focus = 24; | ||
| 40 | // PID of a local client process, for bar scripts. 0 when unknown or remote. | 41 | // PID of a local client process, for bar scripts. 0 when unknown or remote. | ||
| 41 | uint32 client_pid = 25; | 42 | uint32 client_pid = 25; | ||
| 43 | + | // Color output capability the client claims. Required: a missing message | |||
| 44 | + | // or an UNSPECIFIED mode is a decode error. | |||
| 45 | + | ColorCapabilities color = 26; | |||
| 42 | } | 46 | } | ||
| 43 | 47 | ||||
| 44 | // Welcome answers an accepted attach. | 48 | // Welcome answers an accepted attach. | ||
M internal/protocol/wire/schema/terminal.proto +2 -1
| @@ -62,7 +62,8 @@ message Theme { | |||
| 62 | 62 | RGB foreground = 2; | |
| 63 | 63 | bool has_background = 3; | |
| 64 | 64 | RGB background = 4; | |
| 65 | - | bool true_color = 5; | |
| 65 | + | reserved 5; | |
| 66 | + | reserved "true_color"; | |
| 66 | 67 | bool scheme_known = 6; | |
| 67 | 68 | bool light = 7; | |
| 68 | 69 | uint32 palette_known = 8; | |
| @@ -62,7 +62,8 @@ message Theme { | |||||
| 62 | RGB foreground = 2; | 62 | RGB foreground = 2; | ||
| 63 | bool has_background = 3; | 63 | bool has_background = 3; | ||
| 64 | RGB background = 4; | 64 | RGB background = 4; | ||
| 65 | - | bool true_color = 5; | 65 | + | reserved 5; |
| 66 | + | reserved "true_color"; | |||
| 66 | bool scheme_known = 6; | 67 | bool scheme_known = 6; | ||
| 67 | bool light = 7; | 68 | bool light = 7; | ||
| 68 | uint32 palette_known = 8; | 69 | uint32 palette_known = 8; | ||
M internal/protocol/wire/session.pb.go +46 -42 generated file: collapsed
M internal/protocol/wire/terminal.pb.go +4 -13 generated file: collapsed
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(*protocol.Theme) {}) | |
| 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(*protocol.Theme) {}) |
| 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 -2
| @@ -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() { | |
| @@ -188,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) { | |||
| 188 | 189 | first.awaitQueries(t, paletteColorBatch, 1) | |
| 189 | 190 | require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame") | |
| 190 | 191 | cleared := first.awaitThemes(t, 1)[0] | |
| 191 | - | require.True(t, cleared.TrueColor) | |
| 192 | 192 | require.Zero(t, cleared.PaletteKnown) | |
| 193 | 193 | require.False(t, cleared.HasForeground) | |
| 194 | 194 | ||
| @@ -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() { | ||
| @@ -188,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) { | |||||
| 188 | first.awaitQueries(t, paletteColorBatch, 1) | 189 | first.awaitQueries(t, paletteColorBatch, 1) | ||
| 189 | require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame") | 190 | require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame") | ||
| 190 | cleared := first.awaitThemes(t, 1)[0] | 191 | cleared := first.awaitThemes(t, 1)[0] | ||
| 191 | - | require.True(t, cleared.TrueColor) | |||
| 192 | require.Zero(t, cleared.PaletteKnown) | 192 | require.Zero(t, cleared.PaletteKnown) | ||
| 193 | require.False(t, cleared.HasForeground) | 193 | require.False(t, cleared.HasForeground) | ||
| 194 | 194 | ||||
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 | + | Color: w.cfg.Color, | |
| 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 | + | Color: w.cfg.Color, |
| 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 +7 -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,12 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 528 | 529 | tests := []struct { | |
| 529 | 530 | name string | |
| 530 | 531 | env AttachmentEnvironment | |
| 532 | + | want terminalcap.ColorCapabilities | |
| 531 | 533 | }{ | |
| 532 | - | {name: "unset"}, | |
| 533 | - | {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}}, | |
| 534 | + | {name: "unset defaults to truecolor", want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, | |
| 535 | + | {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |
| 536 | + | {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |
| 537 | + | {name: "supplied forced ansi16", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |
| 534 | 538 | } | |
| 535 | 539 | for _, tt := range tests { | |
| 536 | 540 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -551,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 551 | 555 | hello := awaitHello(t, stream) | |
| 552 | 556 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | |
| 553 | 557 | require.Equal(t, tt.env.Cwd, hello.Cwd) | |
| 554 | - | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | |
| 558 | + | require.Equal(t, tt.want, hello.Color) | |
| 555 | 559 | require.NoError(t, stream.Close()) | |
| 556 | 560 | <-events | |
| 557 | 561 | }) | |
| @@ -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,12 @@ 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 terminalcap.ColorCapabilities | |||
| 531 | }{ | 533 | }{ | ||
| 532 | - | {name: "unset"}, | 534 | + | {name: "unset defaults to truecolor", want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, |
| 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, Source: terminalcap.SourceDeclared}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, |
| 536 | + | {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |||
| 537 | + | {name: "supplied forced ansi16", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |||
| 534 | } | 538 | } | ||
| 535 | for _, tt := range tests { | 539 | for _, tt := range tests { | ||
| 536 | t.Run(tt.name, func(t *testing.T) { | 540 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -551,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||||
| 551 | hello := awaitHello(t, stream) | 555 | hello := awaitHello(t, stream) | ||
| 552 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | 556 | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | ||
| 553 | require.Equal(t, tt.env.Cwd, hello.Cwd) | 557 | require.Equal(t, tt.env.Cwd, hello.Cwd) | ||
| 554 | - | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | 558 | + | require.Equal(t, tt.want, hello.Color) |
| 555 | require.NoError(t, stream.Close()) | 559 | require.NoError(t, stream.Close()) | ||
| 556 | <-events | 560 | <-events | ||
| 557 | }) | 561 | }) | ||
M internal/usecase/client/client_toast.go +42 -2
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -16,6 +17,12 @@ import ( | |||
| 16 | 17 | // reconciliation; input pumps must only publish a request for it. | |
| 17 | 18 | // The anchor places it: transitions stay centered, notices sit top-right so | |
| 18 | 19 | // they never cover the picker list. The border is colored by severity. | |
| 20 | + | // | |
| 21 | + | // The border uses fixed xterm-256 colors and does not follow the attachment | |
| 22 | + | // color profile, so production callers (the transition notice) pass only | |
| 23 | + | // domain.NoticeInfo, which emits no color. Toasts with a severity that must | |
| 24 | + | // honor 16-color or monochrome terminals go through toastBorderSGRFor, as the | |
| 25 | + | // picker does. | |
| 19 | 26 | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) { | |
| 20 | 27 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | |
| 21 | 28 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| @@ -38,6 +45,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string { | |||
| 38 | 45 | } | |
| 39 | 46 | } | |
| 40 | 47 | ||
| 48 | + | // toastBorderSGRFor is toastBorderSGR for one renderer color profile. The | |
| 49 | + | // toast bypasses the renderer, so it must follow the profile itself: 16-color | |
| 50 | + | // terminals get basic red/yellow, monochrome terminals get bold for problems. | |
| 51 | + | // TrueColor and 256-color output is unchanged. | |
| 52 | + | func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string { | |
| 53 | + | switch profile { | |
| 54 | + | case ansirenderer.ColorProfileANSI16: | |
| 55 | + | switch severity { | |
| 56 | + | case domain.NoticeError: | |
| 57 | + | return "\x1b[31m" | |
| 58 | + | case domain.NoticeWarn: | |
| 59 | + | return "\x1b[33m" | |
| 60 | + | } | |
| 61 | + | return "" | |
| 62 | + | case ansirenderer.ColorProfileMonochrome: | |
| 63 | + | if severity == domain.NoticeError || severity == domain.NoticeWarn { | |
| 64 | + | return "\x1b[1m" | |
| 65 | + | } | |
| 66 | + | return "" | |
| 67 | + | default: | |
| 68 | + | return toastBorderSGR(severity) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // toastBorderReset undoes toastBorderSGR's attribute: default foreground for | |
| 73 | + | // colors, normal intensity for the monochrome bold border. | |
| 74 | + | func toastBorderReset(borderSGR string) string { | |
| 75 | + | if borderSGR == "\x1b[1m" { | |
| 76 | + | return "\x1b[22m" | |
| 77 | + | } | |
| 78 | + | return "\x1b[39m" | |
| 79 | + | } | |
| 80 | + | ||
| 41 | 81 | // clientToastLines renders the toast box as one string per row, exactly | |
| 42 | 82 | // bounds.Width columns wide. | |
| 43 | 83 | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |
| @@ -60,7 +100,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 60 | 100 | if border { | |
| 61 | 101 | b.WriteString(borderSGR) | |
| 62 | 102 | } else { | |
| 63 | - | b.WriteString("\x1b[39m") | |
| 103 | + | b.WriteString(toastBorderReset(borderSGR)) | |
| 64 | 104 | } | |
| 65 | 105 | colored = border | |
| 66 | 106 | } | |
| @@ -76,7 +116,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 76 | 116 | b.WriteRune(cell.Rune) | |
| 77 | 117 | } | |
| 78 | 118 | if colored { | |
| 79 | - | b.WriteString("\x1b[39m") | |
| 119 | + | b.WriteString(toastBorderReset(borderSGR)) | |
| 80 | 120 | } | |
| 81 | 121 | lines[y] = b.String() | |
| 82 | 122 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/usecase/picker" | 11 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 11 | "github.com/bnema/vev/internal/usecase/ui" | 12 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -16,6 +17,12 @@ import ( | |||||
| 16 | // reconciliation; input pumps must only publish a request for it. | 17 | // reconciliation; input pumps must only publish a request for it. | ||
| 17 | // The anchor places it: transitions stay centered, notices sit top-right so | 18 | // The anchor places it: transitions stay centered, notices sit top-right so | ||
| 18 | // they never cover the picker list. The border is colored by severity. | 19 | // they never cover the picker list. The border is colored by severity. | ||
| 20 | + | // | |||
| 21 | + | // The border uses fixed xterm-256 colors and does not follow the attachment | |||
| 22 | + | // color profile, so production callers (the transition notice) pass only | |||
| 23 | + | // domain.NoticeInfo, which emits no color. Toasts with a severity that must | |||
| 24 | + | // honor 16-color or monochrome terminals go through toastBorderSGRFor, as the | |||
| 25 | + | // picker does. | |||
| 19 | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) { | 26 | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) { | ||
| 20 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | 27 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | ||
| 21 | if bounds.Width <= 0 || bounds.Height <= 0 { | 28 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| @@ -38,6 +45,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string { | |||||
| 38 | } | 45 | } | ||
| 39 | } | 46 | } | ||
| 40 | 47 | ||||
| 48 | + | // toastBorderSGRFor is toastBorderSGR for one renderer color profile. The | |||
| 49 | + | // toast bypasses the renderer, so it must follow the profile itself: 16-color | |||
| 50 | + | // terminals get basic red/yellow, monochrome terminals get bold for problems. | |||
| 51 | + | // TrueColor and 256-color output is unchanged. | |||
| 52 | + | func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string { | |||
| 53 | + | switch profile { | |||
| 54 | + | case ansirenderer.ColorProfileANSI16: | |||
| 55 | + | switch severity { | |||
| 56 | + | case domain.NoticeError: | |||
| 57 | + | return "\x1b[31m" | |||
| 58 | + | case domain.NoticeWarn: | |||
| 59 | + | return "\x1b[33m" | |||
| 60 | + | } | |||
| 61 | + | return "" | |||
| 62 | + | case ansirenderer.ColorProfileMonochrome: | |||
| 63 | + | if severity == domain.NoticeError || severity == domain.NoticeWarn { | |||
| 64 | + | return "\x1b[1m" | |||
| 65 | + | } | |||
| 66 | + | return "" | |||
| 67 | + | default: | |||
| 68 | + | return toastBorderSGR(severity) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // toastBorderReset undoes toastBorderSGR's attribute: default foreground for | |||
| 73 | + | // colors, normal intensity for the monochrome bold border. | |||
| 74 | + | func toastBorderReset(borderSGR string) string { | |||
| 75 | + | if borderSGR == "\x1b[1m" { | |||
| 76 | + | return "\x1b[22m" | |||
| 77 | + | } | |||
| 78 | + | return "\x1b[39m" | |||
| 79 | + | } | |||
| 80 | + | ||||
| 41 | // clientToastLines renders the toast box as one string per row, exactly | 81 | // clientToastLines renders the toast box as one string per row, exactly | ||
| 42 | // bounds.Width columns wide. | 82 | // bounds.Width columns wide. | ||
| 43 | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | 83 | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | ||
| @@ -60,7 +100,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||||
| 60 | if border { | 100 | if border { | ||
| 61 | b.WriteString(borderSGR) | 101 | b.WriteString(borderSGR) | ||
| 62 | } else { | 102 | } else { | ||
| 63 | - | b.WriteString("\x1b[39m") | 103 | + | b.WriteString(toastBorderReset(borderSGR)) |
| 64 | } | 104 | } | ||
| 65 | colored = border | 105 | colored = border | ||
| 66 | } | 106 | } | ||
| @@ -76,7 +116,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||||
| 76 | b.WriteRune(cell.Rune) | 116 | b.WriteRune(cell.Rune) | ||
| 77 | } | 117 | } | ||
| 78 | if colored { | 118 | if colored { | ||
| 79 | - | b.WriteString("\x1b[39m") | 119 | + | b.WriteString(toastBorderReset(borderSGR)) |
| 80 | } | 120 | } | ||
| 81 | lines[y] = b.String() | 121 | lines[y] = b.String() | ||
| 82 | } | 122 | } | ||
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/palette_generation.go +0 -1
| @@ -141,7 +141,6 @@ func (c *paletteGenerationCoordinator) start(retained protocol.Theme, replacemen | |||
| 141 | 141 | id: id, | |
| 142 | 142 | phase: generationCollecting, | |
| 143 | 143 | theme: protocol.Theme{ | |
| 144 | - | TrueColor: retained.TrueColor, | |
| 145 | 144 | SchemeKnown: retained.SchemeKnown, | |
| 146 | 145 | Light: retained.Light, | |
| 147 | 146 | }, | |
| @@ -141,7 +141,6 @@ func (c *paletteGenerationCoordinator) start(retained protocol.Theme, replacemen | |||||
| 141 | id: id, | 141 | id: id, | ||
| 142 | phase: generationCollecting, | 142 | phase: generationCollecting, | ||
| 143 | theme: protocol.Theme{ | 143 | theme: protocol.Theme{ | ||
| 144 | - | TrueColor: retained.TrueColor, | |||
| 145 | SchemeKnown: retained.SchemeKnown, | 144 | SchemeKnown: retained.SchemeKnown, | ||
| 146 | Light: retained.Light, | 145 | Light: retained.Light, | ||
| 147 | }, | 146 | }, | ||
M internal/usecase/client/palette_generation_test.go +0 -2
| @@ -54,7 +54,6 @@ func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id | |||
| 54 | 54 | ||
| 55 | 55 | func generationTheme() protocol.Theme { | |
| 56 | 56 | return protocol.Theme{ | |
| 57 | - | TrueColor: true, | |
| 58 | 57 | HasForeground: true, | |
| 59 | 58 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 60 | 59 | HasBackground: true, | |
| @@ -119,7 +118,6 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |||
| 119 | 118 | paletteActionPublishFinal, | |
| 120 | 119 | }, actionKinds(final)) | |
| 121 | 120 | got := findAction(t, final, paletteActionPublishFinal).theme | |
| 122 | - | require.True(t, got.TrueColor) | |
| 123 | 121 | require.True(t, got.HasForeground) | |
| 124 | 122 | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | |
| 125 | 123 | require.True(t, got.HasBackground) | |
| @@ -54,7 +54,6 @@ func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id | |||||
| 54 | 54 | ||||
| 55 | func generationTheme() protocol.Theme { | 55 | func generationTheme() protocol.Theme { | ||
| 56 | return protocol.Theme{ | 56 | return protocol.Theme{ | ||
| 57 | - | TrueColor: true, | |||
| 58 | HasForeground: true, | 57 | HasForeground: true, | ||
| 59 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 58 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 60 | HasBackground: true, | 59 | HasBackground: true, | ||
| @@ -119,7 +118,6 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |||||
| 119 | paletteActionPublishFinal, | 118 | paletteActionPublishFinal, | ||
| 120 | }, actionKinds(final)) | 119 | }, actionKinds(final)) | ||
| 121 | got := findAction(t, final, paletteActionPublishFinal).theme | 120 | got := findAction(t, final, paletteActionPublishFinal).theme | ||
| 122 | - | require.True(t, got.TrueColor) | |||
| 123 | require.True(t, got.HasForeground) | 121 | require.True(t, got.HasForeground) | ||
| 124 | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | 122 | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | ||
| 125 | require.True(t, got.HasBackground) | 123 | require.True(t, got.HasBackground) | ||
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 +5 -3
| @@ -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, | |
| @@ -496,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||
| 496 | 498 | } | |
| 497 | 499 | buffer := bytes.NewBuffer(blanks) | |
| 498 | 500 | for i, rect := range p.noticeBounds { | |
| 499 | - | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity))) | |
| 501 | + | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile))) | |
| 500 | 502 | } | |
| 501 | 503 | if buffer.Len() == 0 { | |
| 502 | 504 | return nil | |
| @@ -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, | ||
| @@ -496,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||||
| 496 | } | 498 | } | ||
| 497 | buffer := bytes.NewBuffer(blanks) | 499 | buffer := bytes.NewBuffer(blanks) | ||
| 498 | for i, rect := range p.noticeBounds { | 500 | for i, rect := range p.noticeBounds { | ||
| 499 | - | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity))) | 501 | + | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile))) |
| 500 | } | 502 | } | ||
| 501 | if buffer.Len() == 0 { | 503 | if buffer.Len() == 0 { | ||
| 502 | return nil | 504 | return nil | ||
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 +5 -11
| @@ -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 | |
| @@ -389,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||
| 389 | 379 | border := renderer.DefaultStyle() | |
| 390 | 380 | border.Attrs |= renderer.AttrDim | |
| 391 | 381 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | |
| 392 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) | |
| 382 | + | styles := r.renderStyles | |
| 383 | + | if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome { | |
| 384 | + | styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)} | |
| 385 | + | } | |
| 386 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...) | |
| 393 | 387 | copyFrameRect(base, presentation.Inner, inner) | |
| 394 | 388 | ||
| 395 | 389 | // Only the box is written: the session stays on screen around it. A box | |
| @@ -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 | ||
| @@ -389,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||||
| 389 | border := renderer.DefaultStyle() | 379 | border := renderer.DefaultStyle() | ||
| 390 | border.Attrs |= renderer.AttrDim | 380 | border.Attrs |= renderer.AttrDim | ||
| 391 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | 381 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | ||
| 392 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) | 382 | + | styles := r.renderStyles |
| 383 | + | if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome { | |||
| 384 | + | styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)} | |||
| 385 | + | } | |||
| 386 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...) | |||
| 393 | copyFrameRect(base, presentation.Inner, inner) | 387 | copyFrameRect(base, presentation.Inner, inner) | ||
| 394 | 388 | ||||
| 395 | // Only the box is written: the session stays on screen around it. A box | 389 | // Only the box is written: the session stays on screen around it. A box | ||
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())) | ||
A internal/usecase/client/reduced_color_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestPickerStaysLegibleInReducedColor(t *testing.T) { | |
| 16 | + | loop := pickerLoopFixture(t) | |
| 17 | + | size := domain.Size{Cols: 100, Rows: 30} | |
| 18 | + | ||
| 19 | + | ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview())) | |
| 20 | + | require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors") | |
| 21 | + | require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video") | |
| 22 | + | ||
| 23 | + | mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview())) | |
| 24 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color") | |
| 25 | + | require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video") | |
| 26 | + | require.Contains(t, mono, "work") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestToastBorderFollowsColorProfile(t *testing.T) { | |
| 30 | + | tests := []struct { | |
| 31 | + | name string | |
| 32 | + | profile ansirenderer.ColorProfile | |
| 33 | + | severity domain.NoticeSeverity | |
| 34 | + | want string | |
| 35 | + | }{ | |
| 36 | + | {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"}, | |
| 37 | + | {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"}, | |
| 38 | + | {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo}, | |
| 39 | + | {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"}, | |
| 40 | + | {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"}, | |
| 41 | + | {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo}, | |
| 42 | + | {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"}, | |
| 43 | + | {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"}, | |
| 44 | + | {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo}, | |
| 45 | + | } | |
| 46 | + | for _, tt := range tests { | |
| 47 | + | t.Run(tt.name, func(t *testing.T) { | |
| 48 | + | require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile)) | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | // Monochrome borders are closed with normal intensity, not default color. | |
| 52 | + | bounds := domain.Rect{Width: 12, Height: 3} | |
| 53 | + | lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome)) | |
| 54 | + | require.Contains(t, lines[0], "\x1b[22m") | |
| 55 | + | require.NotContains(t, lines[0], "\x1b[39m") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes | |
| 59 | + | // the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is | |
| 60 | + | // quantized by the renderer, and monochrome marks error and warning dots bold | |
| 61 | + | // since no color survives. Informational statuses never gain weight. | |
| 62 | + | func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) { | |
| 63 | + | snapshot := pickerSnapshotFixture() | |
| 64 | + | snapshot.Lines = []protocol.PickerLine{ | |
| 65 | + | {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError}, | |
| 66 | + | {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale}, | |
| 67 | + | {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion}, | |
| 68 | + | } | |
| 69 | + | snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"} | |
| 70 | + | // dot returns the SGR group in effect for the dot on the row labelled label. | |
| 71 | + | dot := func(out, label string) string { | |
| 72 | + | row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`) | |
| 73 | + | m := row.FindStringSubmatch(out) | |
| 74 | + | require.NotNil(t, m, "no problem dot after %q in %q", label, out) | |
| 75 | + | return m[1] | |
| 76 | + | } | |
| 77 | + | tests := []struct { | |
| 78 | + | name string | |
| 79 | + | profile ansirenderer.ColorProfile | |
| 80 | + | want map[string]string // label -> exact SGR before the dot | |
| 81 | + | }{ | |
| 82 | + | {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |
| 83 | + | {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |
| 84 | + | {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}}, | |
| 85 | + | {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}}, | |
| 86 | + | } | |
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent) | |
| 90 | + | out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |
| 91 | + | for label, want := range tt.want { | |
| 92 | + | got := dot(out, label) | |
| 93 | + | if want == "" { | |
| 94 | + | // ANSI-16 colors are chosen by the renderer's quantizer: only | |
| 95 | + | // require a basic or bright palette slot, never extended color. | |
| 96 | + | require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label) | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | require.Equal(t, want, got, label) | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestPickerStaysLegibleInReducedColor(t *testing.T) { | |||
| 16 | + | loop := pickerLoopFixture(t) | |||
| 17 | + | size := domain.Size{Cols: 100, Rows: 30} | |||
| 18 | + | ||||
| 19 | + | ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview())) | |||
| 20 | + | require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors") | |||
| 21 | + | require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video") | |||
| 22 | + | ||||
| 23 | + | mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview())) | |||
| 24 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color") | |||
| 25 | + | require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video") | |||
| 26 | + | require.Contains(t, mono, "work") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestToastBorderFollowsColorProfile(t *testing.T) { | |||
| 30 | + | tests := []struct { | |||
| 31 | + | name string | |||
| 32 | + | profile ansirenderer.ColorProfile | |||
| 33 | + | severity domain.NoticeSeverity | |||
| 34 | + | want string | |||
| 35 | + | }{ | |||
| 36 | + | {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"}, | |||
| 37 | + | {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"}, | |||
| 38 | + | {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo}, | |||
| 39 | + | {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"}, | |||
| 40 | + | {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"}, | |||
| 41 | + | {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo}, | |||
| 42 | + | {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"}, | |||
| 43 | + | {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"}, | |||
| 44 | + | {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo}, | |||
| 45 | + | } | |||
| 46 | + | for _, tt := range tests { | |||
| 47 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 48 | + | require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile)) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | // Monochrome borders are closed with normal intensity, not default color. | |||
| 52 | + | bounds := domain.Rect{Width: 12, Height: 3} | |||
| 53 | + | lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome)) | |||
| 54 | + | require.Contains(t, lines[0], "\x1b[22m") | |||
| 55 | + | require.NotContains(t, lines[0], "\x1b[39m") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes | |||
| 59 | + | // the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is | |||
| 60 | + | // quantized by the renderer, and monochrome marks error and warning dots bold | |||
| 61 | + | // since no color survives. Informational statuses never gain weight. | |||
| 62 | + | func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) { | |||
| 63 | + | snapshot := pickerSnapshotFixture() | |||
| 64 | + | snapshot.Lines = []protocol.PickerLine{ | |||
| 65 | + | {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError}, | |||
| 66 | + | {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale}, | |||
| 67 | + | {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion}, | |||
| 68 | + | } | |||
| 69 | + | snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"} | |||
| 70 | + | // dot returns the SGR group in effect for the dot on the row labelled label. | |||
| 71 | + | dot := func(out, label string) string { | |||
| 72 | + | row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`) | |||
| 73 | + | m := row.FindStringSubmatch(out) | |||
| 74 | + | require.NotNil(t, m, "no problem dot after %q in %q", label, out) | |||
| 75 | + | return m[1] | |||
| 76 | + | } | |||
| 77 | + | tests := []struct { | |||
| 78 | + | name string | |||
| 79 | + | profile ansirenderer.ColorProfile | |||
| 80 | + | want map[string]string // label -> exact SGR before the dot | |||
| 81 | + | }{ | |||
| 82 | + | {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |||
| 83 | + | {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |||
| 84 | + | {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}}, | |||
| 85 | + | {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}}, | |||
| 86 | + | } | |||
| 87 | + | for _, tt := range tests { | |||
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 89 | + | loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent) | |||
| 90 | + | out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |||
| 91 | + | for label, want := range tt.want { | |||
| 92 | + | got := dot(out, label) | |||
| 93 | + | if want == "" { | |||
| 94 | + | // ANSI-16 colors are chosen by the renderer's quantizer: only | |||
| 95 | + | // require a basic or bright palette slot, never extended color. | |||
| 96 | + | require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label) | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | require.Equal(t, want, got, label) | |||
| 100 | + | } | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | } | |||
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 +25 -0
| @@ -0,0 +1,25 @@ | |||
| 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 a reduced palette, so RGB surfaces are quantized instead of dropped. | |
| 13 | + | // Unknown modes fall back to the conservative 256-color profile. | |
| 14 | + | func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile { | |
| 15 | + | switch c.Mode { | |
| 16 | + | case terminalcap.TrueColor: | |
| 17 | + | return ansi.ColorProfileTrueColor | |
| 18 | + | case terminalcap.ANSI16: | |
| 19 | + | return ansi.ColorProfileANSI16 | |
| 20 | + | case terminalcap.Monochrome: | |
| 21 | + | return ansi.ColorProfileMonochrome | |
| 22 | + | default: | |
| 23 | + | return ansi.ColorProfileANSI256 | |
| 24 | + | } | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 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 a reduced palette, so RGB surfaces are quantized instead of dropped. | |||
| 13 | + | // Unknown modes fall back to the conservative 256-color profile. | |||
| 14 | + | func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile { | |||
| 15 | + | switch c.Mode { | |||
| 16 | + | case terminalcap.TrueColor: | |||
| 17 | + | return ansi.ColorProfileTrueColor | |||
| 18 | + | case terminalcap.ANSI16: | |||
| 19 | + | return ansi.ColorProfileANSI16 | |||
| 20 | + | case terminalcap.Monochrome: | |||
| 21 | + | return ansi.ColorProfileMonochrome | |||
| 22 | + | default: | |||
| 23 | + | return ansi.ColorProfileANSI256 | |||
| 24 | + | } | |||
| 25 | + | } | |||
A internal/usecase/colorprofile/profile_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 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 | + | {name: "ansi16", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}, want: ansi.ColorProfileANSI16}, | |
| 21 | + | {name: "monochrome", in: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}, want: ansi.ColorProfileMonochrome}, | |
| 22 | + | {name: "forced source does not change mapping", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}, want: ansi.ColorProfileANSI16}, | |
| 23 | + | {name: "unknown mode is conservative", in: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(99)}, want: ansi.ColorProfileANSI256}, | |
| 24 | + | } | |
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | require.Equal(t, tt.want, Profile(tt.in)) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 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 | + | {name: "ansi16", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}, want: ansi.ColorProfileANSI16}, | |||
| 21 | + | {name: "monochrome", in: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}, want: ansi.ColorProfileMonochrome}, | |||
| 22 | + | {name: "forced source does not change mapping", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}, want: ansi.ColorProfileANSI16}, | |||
| 23 | + | {name: "unknown mode is conservative", in: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(99)}, want: ansi.ColorProfileANSI256}, | |||
| 24 | + | } | |||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | require.Equal(t, tt.want, Profile(tt.in)) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
A internal/usecase/daemon/attachment_color_profile_test.go +280 -0
| @@ -0,0 +1,280 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var ( | |
| 14 | + | colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} | |
| 15 | + | color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |
| 16 | + | colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |
| 17 | + | ||
| 18 | + | // paneColorWrites exercise every color channel a pane can carry: RGB | |
| 19 | + | // foreground/background, indexed 16-255 foreground/background, and RGB and | |
| 20 | + | // indexed underline color. | |
| 21 | + | paneColorWrites = []string{ | |
| 22 | + | "\x1b[38;2;10;200;30mR\x1b[0m", | |
| 23 | + | "\x1b[48;2;200;10;30mB\x1b[0m", | |
| 24 | + | "\x1b[38;5;196mF\x1b[0m", | |
| 25 | + | "\x1b[48;5;21mG\x1b[0m", | |
| 26 | + | "\x1b[4;58;2;0;255;0mU\x1b[0m", | |
| 27 | + | "\x1b[4;58;5;200mV\x1b[0m", | |
| 28 | + | } | |
| 29 | + | extendedColor = regexp.MustCompile(`^(38|48|58)`) | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // colorClient is one attachment plus the output frames its transport received. | |
| 33 | + | type colorClient struct { | |
| 34 | + | ac *attachedClient | |
| 35 | + | tr *closeTrackingTransport | |
| 36 | + | seen int | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // newOutput returns the Output messages sent since the previous call and their | |
| 40 | + | // concatenated terminal bytes. | |
| 41 | + | func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) { | |
| 42 | + | t.Helper() | |
| 43 | + | frames := testFramesOfType(c.tr.Sends(), "Output") | |
| 44 | + | var ( | |
| 45 | + | outs []protocol.Output | |
| 46 | + | data []byte | |
| 47 | + | ) | |
| 48 | + | for _, frame := range frames[c.seen:] { | |
| 49 | + | out := unmarshalTestOutput(t, frame.Payload) | |
| 50 | + | outs = append(outs, out) | |
| 51 | + | data = append(data, out.Data...) | |
| 52 | + | } | |
| 53 | + | c.seen = len(frames) | |
| 54 | + | return outs, data | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello { | |
| 58 | + | h := helloResumeCapable(intent, name, token) | |
| 59 | + | h.ClientID = [16]byte{client} | |
| 60 | + | h.Color = color | |
| 61 | + | return h | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // requireProfile asserts data is encoded for color: pane RGB/indexed channels | |
| 65 | + | // survive only where the mode can express them. | |
| 66 | + | func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) { | |
| 67 | + | t.Helper() | |
| 68 | + | params := colorParams(data) | |
| 69 | + | switch mode { | |
| 70 | + | case terminalcap.TrueColor: | |
| 71 | + | require.Contains(t, params, "38;2;10;200;30", "%q", data) | |
| 72 | + | require.Contains(t, params, "48;2;200;10;30", "%q", data) | |
| 73 | + | require.Contains(t, params, "58;2;0;255;0", "%q", data) | |
| 74 | + | require.Contains(t, params, "38;5;196", "%q", data) | |
| 75 | + | require.Contains(t, params, "48;5;21", "%q", data) | |
| 76 | + | require.Contains(t, params, "58;5;200", "%q", data) | |
| 77 | + | case terminalcap.ANSI16: | |
| 78 | + | require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data) | |
| 79 | + | for _, p := range params { | |
| 80 | + | require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data) | |
| 81 | + | } | |
| 82 | + | case terminalcap.Monochrome: | |
| 83 | + | require.Empty(t, params, "monochrome output carries color: %q", data) | |
| 84 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data)) | |
| 85 | + | default: | |
| 86 | + | t.Fatalf("unhandled mode %v", mode) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) { | |
| 91 | + | t.Helper() | |
| 92 | + | pty, release := newBlockingPTY(t) | |
| 93 | + | t.Cleanup(release) | |
| 94 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 95 | + | var ( | |
| 96 | + | sess *session | |
| 97 | + | clients []*colorClient | |
| 98 | + | ) | |
| 99 | + | for i, color := range modes { | |
| 100 | + | tr := &closeTrackingTransport{} | |
| 101 | + | intent := protocol.IntentAttach | |
| 102 | + | if i == 0 { | |
| 103 | + | intent = protocol.IntentNew | |
| 104 | + | } | |
| 105 | + | s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | if sess == nil { | |
| 108 | + | sess = s | |
| 109 | + | } | |
| 110 | + | require.Same(t, sess, s) | |
| 111 | + | require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode) | |
| 112 | + | clients = append(clients, &colorClient{ac: ac, tr: tr}) | |
| 113 | + | } | |
| 114 | + | return d, sess, clients | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for | |
| 118 | + | // attachments of one session that negotiated different color modes: full | |
| 119 | + | // first frame, incremental update, and explicit renderer reset. | |
| 120 | + | func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) { | |
| 121 | + | modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono} | |
| 122 | + | d, sess, clients := attachColorClients(t, modes) | |
| 123 | + | pane := sess.tabs[0].focusedPane() | |
| 124 | + | for _, w := range paneColorWrites { | |
| 125 | + | pane.screen.Write([]byte(w)) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | paintAll := func(reset bool) { | |
| 129 | + | for _, c := range clients { | |
| 130 | + | d.paint(sess, c.ac, reset, nil) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | // Each step repaints every attachment and then checks that attachment's | |
| 134 | + | // bytes against its own mode. | |
| 135 | + | steps := []struct { | |
| 136 | + | name string | |
| 137 | + | setup func() | |
| 138 | + | reset bool | |
| 139 | + | full bool | |
| 140 | + | }{ | |
| 141 | + | {name: "initial full frame", reset: true, full: true}, | |
| 142 | + | {name: "incremental draw", setup: func() { | |
| 143 | + | for _, w := range paneColorWrites { | |
| 144 | + | pane.screen.Write([]byte("\r\n" + w)) | |
| 145 | + | } | |
| 146 | + | }}, | |
| 147 | + | {name: "renderer reset", reset: true, full: true}, | |
| 148 | + | {name: "incremental after reset", setup: func() { | |
| 149 | + | for _, w := range paneColorWrites { | |
| 150 | + | pane.screen.Write([]byte("\r\n" + w)) | |
| 151 | + | } | |
| 152 | + | }}, | |
| 153 | + | } | |
| 154 | + | var prevNew [3]uint64 | |
| 155 | + | for _, step := range steps { | |
| 156 | + | if step.setup != nil { | |
| 157 | + | step.setup() | |
| 158 | + | } | |
| 159 | + | paintAll(step.reset) | |
| 160 | + | for i, c := range clients { | |
| 161 | + | t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) { | |
| 162 | + | outs, data := c.newOutput(t) | |
| 163 | + | require.NotEmpty(t, outs) | |
| 164 | + | first := outs[0] | |
| 165 | + | if step.full { | |
| 166 | + | require.Zero(t, first.Base, "full frames start a new chain") | |
| 167 | + | } else { | |
| 168 | + | require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain") | |
| 169 | + | } | |
| 170 | + | prevNew[i] = outs[len(outs)-1].New | |
| 171 | + | c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |
| 172 | + | requireProfile(t, modes[i].Mode, data) | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func modeName(m terminalcap.ColorMode) string { | |
| 179 | + | switch m { | |
| 180 | + | case terminalcap.TrueColor: | |
| 181 | + | return "truecolor" | |
| 182 | + | case terminalcap.ANSI16: | |
| 183 | + | return "ansi16" | |
| 184 | + | case terminalcap.Monochrome: | |
| 185 | + | return "monochrome" | |
| 186 | + | default: | |
| 187 | + | return "other" | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for | |
| 192 | + | // the replacement terminal's Hello.Color, not the one it parked with. | |
| 193 | + | func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |
| 194 | + | tests := []struct { | |
| 195 | + | name string | |
| 196 | + | from, to terminalcap.ColorCapabilities | |
| 197 | + | liveResume bool // resume while the old link is still registered | |
| 198 | + | // wantToasts is checked only when checkToasts is set; it lists the | |
| 199 | + | // visible toast messages after resume. | |
| 200 | + | checkToasts bool | |
| 201 | + | wantToasts []string | |
| 202 | + | }{ | |
| 203 | + | {name: "truecolor to monochrome", from: colorTrue, to: colorMono}, | |
| 204 | + | {name: "truecolor to ansi16", from: colorTrue, to: color16}, | |
| 205 | + | { | |
| 206 | + | name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16, | |
| 207 | + | checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."}, | |
| 208 | + | }, | |
| 209 | + | { | |
| 210 | + | name: "forced monochrome resume shows no notice", from: colorTrue, | |
| 211 | + | to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}, | |
| 212 | + | checkToasts: true, | |
| 213 | + | }, | |
| 214 | + | {name: "monochrome to truecolor", from: colorMono, to: colorTrue}, | |
| 215 | + | {name: "ansi16 to truecolor", from: color16, to: colorTrue}, | |
| 216 | + | {name: "ansi16 to monochrome", from: color16, to: colorMono}, | |
| 217 | + | {name: "same mode", from: color16, to: color16}, | |
| 218 | + | {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true}, | |
| 219 | + | {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true}, | |
| 220 | + | } | |
| 221 | + | for _, tt := range tests { | |
| 222 | + | t.Run(tt.name, func(t *testing.T) { | |
| 223 | + | pty, release := newBlockingPTY(t) | |
| 224 | + | defer release() | |
| 225 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 226 | + | oldTr := &closeTrackingTransport{} | |
| 227 | + | sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr) | |
| 228 | + | require.NoError(t, err) | |
| 229 | + | for _, w := range paneColorWrites { | |
| 230 | + | sess.tabs[0].focusedPane().screen.Write([]byte(w)) | |
| 231 | + | } | |
| 232 | + | before := &colorClient{ac: ac, tr: oldTr} | |
| 233 | + | d.paint(sess, ac, true, nil) | |
| 234 | + | _, data := before.newOutput(t) | |
| 235 | + | requireProfile(t, tt.from.Mode, data) | |
| 236 | + | ||
| 237 | + | token := ac.resumeToken | |
| 238 | + | if !tt.liveResume { | |
| 239 | + | d.clientGone(sess, ac, oldTr, false) | |
| 240 | + | } | |
| 241 | + | newTr := &closeTrackingTransport{} | |
| 242 | + | resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | require.Same(t, sess, resumedSess) | |
| 245 | + | require.Same(t, ac, resumed) | |
| 246 | + | require.Equal(t, tt.to, resumed.terminalCapabilities.Color) | |
| 247 | + | ||
| 248 | + | after := &colorClient{ac: resumed, tr: newTr} | |
| 249 | + | d.paint(resumedSess, resumed, true, nil) | |
| 250 | + | outs, data := after.newOutput(t) | |
| 251 | + | require.NotEmpty(t, outs) | |
| 252 | + | require.Zero(t, outs[0].Base, "resume starts with a full frame") | |
| 253 | + | requireProfile(t, tt.to.Mode, data) | |
| 254 | + | resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |
| 255 | + | ||
| 256 | + | // The profile also holds for incremental updates after resume. | |
| 257 | + | for _, w := range paneColorWrites { | |
| 258 | + | sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w)) | |
| 259 | + | } | |
| 260 | + | d.paint(resumedSess, resumed, false, nil) | |
| 261 | + | outs, data = after.newOutput(t) | |
| 262 | + | require.NotEmpty(t, outs) | |
| 263 | + | require.NotZero(t, outs[0].Base) | |
| 264 | + | requireProfile(t, tt.to.Mode, data) | |
| 265 | + | ||
| 266 | + | if tt.checkToasts { | |
| 267 | + | toasts, _ := visibleToasts(resumed) | |
| 268 | + | var got []string | |
| 269 | + | for _, toast := range toasts { | |
| 270 | + | got = append(got, toast.Message) | |
| 271 | + | } | |
| 272 | + | require.Equal(t, tt.wantToasts, got) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Chrome styling follows the new mode as well. | |
| 276 | + | reduced := tt.to.Mode != terminalcap.TrueColor | |
| 277 | + | require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute) | |
| 278 | + | }) | |
| 279 | + | } | |
| 280 | + | } | |
| @@ -0,0 +1,280 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var ( | |||
| 14 | + | colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} | |||
| 15 | + | color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |||
| 16 | + | colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |||
| 17 | + | ||||
| 18 | + | // paneColorWrites exercise every color channel a pane can carry: RGB | |||
| 19 | + | // foreground/background, indexed 16-255 foreground/background, and RGB and | |||
| 20 | + | // indexed underline color. | |||
| 21 | + | paneColorWrites = []string{ | |||
| 22 | + | "\x1b[38;2;10;200;30mR\x1b[0m", | |||
| 23 | + | "\x1b[48;2;200;10;30mB\x1b[0m", | |||
| 24 | + | "\x1b[38;5;196mF\x1b[0m", | |||
| 25 | + | "\x1b[48;5;21mG\x1b[0m", | |||
| 26 | + | "\x1b[4;58;2;0;255;0mU\x1b[0m", | |||
| 27 | + | "\x1b[4;58;5;200mV\x1b[0m", | |||
| 28 | + | } | |||
| 29 | + | extendedColor = regexp.MustCompile(`^(38|48|58)`) | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // colorClient is one attachment plus the output frames its transport received. | |||
| 33 | + | type colorClient struct { | |||
| 34 | + | ac *attachedClient | |||
| 35 | + | tr *closeTrackingTransport | |||
| 36 | + | seen int | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // newOutput returns the Output messages sent since the previous call and their | |||
| 40 | + | // concatenated terminal bytes. | |||
| 41 | + | func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) { | |||
| 42 | + | t.Helper() | |||
| 43 | + | frames := testFramesOfType(c.tr.Sends(), "Output") | |||
| 44 | + | var ( | |||
| 45 | + | outs []protocol.Output | |||
| 46 | + | data []byte | |||
| 47 | + | ) | |||
| 48 | + | for _, frame := range frames[c.seen:] { | |||
| 49 | + | out := unmarshalTestOutput(t, frame.Payload) | |||
| 50 | + | outs = append(outs, out) | |||
| 51 | + | data = append(data, out.Data...) | |||
| 52 | + | } | |||
| 53 | + | c.seen = len(frames) | |||
| 54 | + | return outs, data | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello { | |||
| 58 | + | h := helloResumeCapable(intent, name, token) | |||
| 59 | + | h.ClientID = [16]byte{client} | |||
| 60 | + | h.Color = color | |||
| 61 | + | return h | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // requireProfile asserts data is encoded for color: pane RGB/indexed channels | |||
| 65 | + | // survive only where the mode can express them. | |||
| 66 | + | func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) { | |||
| 67 | + | t.Helper() | |||
| 68 | + | params := colorParams(data) | |||
| 69 | + | switch mode { | |||
| 70 | + | case terminalcap.TrueColor: | |||
| 71 | + | require.Contains(t, params, "38;2;10;200;30", "%q", data) | |||
| 72 | + | require.Contains(t, params, "48;2;200;10;30", "%q", data) | |||
| 73 | + | require.Contains(t, params, "58;2;0;255;0", "%q", data) | |||
| 74 | + | require.Contains(t, params, "38;5;196", "%q", data) | |||
| 75 | + | require.Contains(t, params, "48;5;21", "%q", data) | |||
| 76 | + | require.Contains(t, params, "58;5;200", "%q", data) | |||
| 77 | + | case terminalcap.ANSI16: | |||
| 78 | + | require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data) | |||
| 79 | + | for _, p := range params { | |||
| 80 | + | require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data) | |||
| 81 | + | } | |||
| 82 | + | case terminalcap.Monochrome: | |||
| 83 | + | require.Empty(t, params, "monochrome output carries color: %q", data) | |||
| 84 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data)) | |||
| 85 | + | default: | |||
| 86 | + | t.Fatalf("unhandled mode %v", mode) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) { | |||
| 91 | + | t.Helper() | |||
| 92 | + | pty, release := newBlockingPTY(t) | |||
| 93 | + | t.Cleanup(release) | |||
| 94 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 95 | + | var ( | |||
| 96 | + | sess *session | |||
| 97 | + | clients []*colorClient | |||
| 98 | + | ) | |||
| 99 | + | for i, color := range modes { | |||
| 100 | + | tr := &closeTrackingTransport{} | |||
| 101 | + | intent := protocol.IntentAttach | |||
| 102 | + | if i == 0 { | |||
| 103 | + | intent = protocol.IntentNew | |||
| 104 | + | } | |||
| 105 | + | s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | if sess == nil { | |||
| 108 | + | sess = s | |||
| 109 | + | } | |||
| 110 | + | require.Same(t, sess, s) | |||
| 111 | + | require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode) | |||
| 112 | + | clients = append(clients, &colorClient{ac: ac, tr: tr}) | |||
| 113 | + | } | |||
| 114 | + | return d, sess, clients | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for | |||
| 118 | + | // attachments of one session that negotiated different color modes: full | |||
| 119 | + | // first frame, incremental update, and explicit renderer reset. | |||
| 120 | + | func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) { | |||
| 121 | + | modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono} | |||
| 122 | + | d, sess, clients := attachColorClients(t, modes) | |||
| 123 | + | pane := sess.tabs[0].focusedPane() | |||
| 124 | + | for _, w := range paneColorWrites { | |||
| 125 | + | pane.screen.Write([]byte(w)) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | paintAll := func(reset bool) { | |||
| 129 | + | for _, c := range clients { | |||
| 130 | + | d.paint(sess, c.ac, reset, nil) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | // Each step repaints every attachment and then checks that attachment's | |||
| 134 | + | // bytes against its own mode. | |||
| 135 | + | steps := []struct { | |||
| 136 | + | name string | |||
| 137 | + | setup func() | |||
| 138 | + | reset bool | |||
| 139 | + | full bool | |||
| 140 | + | }{ | |||
| 141 | + | {name: "initial full frame", reset: true, full: true}, | |||
| 142 | + | {name: "incremental draw", setup: func() { | |||
| 143 | + | for _, w := range paneColorWrites { | |||
| 144 | + | pane.screen.Write([]byte("\r\n" + w)) | |||
| 145 | + | } | |||
| 146 | + | }}, | |||
| 147 | + | {name: "renderer reset", reset: true, full: true}, | |||
| 148 | + | {name: "incremental after reset", setup: func() { | |||
| 149 | + | for _, w := range paneColorWrites { | |||
| 150 | + | pane.screen.Write([]byte("\r\n" + w)) | |||
| 151 | + | } | |||
| 152 | + | }}, | |||
| 153 | + | } | |||
| 154 | + | var prevNew [3]uint64 | |||
| 155 | + | for _, step := range steps { | |||
| 156 | + | if step.setup != nil { | |||
| 157 | + | step.setup() | |||
| 158 | + | } | |||
| 159 | + | paintAll(step.reset) | |||
| 160 | + | for i, c := range clients { | |||
| 161 | + | t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) { | |||
| 162 | + | outs, data := c.newOutput(t) | |||
| 163 | + | require.NotEmpty(t, outs) | |||
| 164 | + | first := outs[0] | |||
| 165 | + | if step.full { | |||
| 166 | + | require.Zero(t, first.Base, "full frames start a new chain") | |||
| 167 | + | } else { | |||
| 168 | + | require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain") | |||
| 169 | + | } | |||
| 170 | + | prevNew[i] = outs[len(outs)-1].New | |||
| 171 | + | c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |||
| 172 | + | requireProfile(t, modes[i].Mode, data) | |||
| 173 | + | }) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func modeName(m terminalcap.ColorMode) string { | |||
| 179 | + | switch m { | |||
| 180 | + | case terminalcap.TrueColor: | |||
| 181 | + | return "truecolor" | |||
| 182 | + | case terminalcap.ANSI16: | |||
| 183 | + | return "ansi16" | |||
| 184 | + | case terminalcap.Monochrome: | |||
| 185 | + | return "monochrome" | |||
| 186 | + | default: | |||
| 187 | + | return "other" | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for | |||
| 192 | + | // the replacement terminal's Hello.Color, not the one it parked with. | |||
| 193 | + | func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |||
| 194 | + | tests := []struct { | |||
| 195 | + | name string | |||
| 196 | + | from, to terminalcap.ColorCapabilities | |||
| 197 | + | liveResume bool // resume while the old link is still registered | |||
| 198 | + | // wantToasts is checked only when checkToasts is set; it lists the | |||
| 199 | + | // visible toast messages after resume. | |||
| 200 | + | checkToasts bool | |||
| 201 | + | wantToasts []string | |||
| 202 | + | }{ | |||
| 203 | + | {name: "truecolor to monochrome", from: colorTrue, to: colorMono}, | |||
| 204 | + | {name: "truecolor to ansi16", from: colorTrue, to: color16}, | |||
| 205 | + | { | |||
| 206 | + | name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16, | |||
| 207 | + | checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."}, | |||
| 208 | + | }, | |||
| 209 | + | { | |||
| 210 | + | name: "forced monochrome resume shows no notice", from: colorTrue, | |||
| 211 | + | to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}, | |||
| 212 | + | checkToasts: true, | |||
| 213 | + | }, | |||
| 214 | + | {name: "monochrome to truecolor", from: colorMono, to: colorTrue}, | |||
| 215 | + | {name: "ansi16 to truecolor", from: color16, to: colorTrue}, | |||
| 216 | + | {name: "ansi16 to monochrome", from: color16, to: colorMono}, | |||
| 217 | + | {name: "same mode", from: color16, to: color16}, | |||
| 218 | + | {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true}, | |||
| 219 | + | {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true}, | |||
| 220 | + | } | |||
| 221 | + | for _, tt := range tests { | |||
| 222 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 223 | + | pty, release := newBlockingPTY(t) | |||
| 224 | + | defer release() | |||
| 225 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 226 | + | oldTr := &closeTrackingTransport{} | |||
| 227 | + | sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr) | |||
| 228 | + | require.NoError(t, err) | |||
| 229 | + | for _, w := range paneColorWrites { | |||
| 230 | + | sess.tabs[0].focusedPane().screen.Write([]byte(w)) | |||
| 231 | + | } | |||
| 232 | + | before := &colorClient{ac: ac, tr: oldTr} | |||
| 233 | + | d.paint(sess, ac, true, nil) | |||
| 234 | + | _, data := before.newOutput(t) | |||
| 235 | + | requireProfile(t, tt.from.Mode, data) | |||
| 236 | + | ||||
| 237 | + | token := ac.resumeToken | |||
| 238 | + | if !tt.liveResume { | |||
| 239 | + | d.clientGone(sess, ac, oldTr, false) | |||
| 240 | + | } | |||
| 241 | + | newTr := &closeTrackingTransport{} | |||
| 242 | + | resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr) | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | require.Same(t, sess, resumedSess) | |||
| 245 | + | require.Same(t, ac, resumed) | |||
| 246 | + | require.Equal(t, tt.to, resumed.terminalCapabilities.Color) | |||
| 247 | + | ||||
| 248 | + | after := &colorClient{ac: resumed, tr: newTr} | |||
| 249 | + | d.paint(resumedSess, resumed, true, nil) | |||
| 250 | + | outs, data := after.newOutput(t) | |||
| 251 | + | require.NotEmpty(t, outs) | |||
| 252 | + | require.Zero(t, outs[0].Base, "resume starts with a full frame") | |||
| 253 | + | requireProfile(t, tt.to.Mode, data) | |||
| 254 | + | resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |||
| 255 | + | ||||
| 256 | + | // The profile also holds for incremental updates after resume. | |||
| 257 | + | for _, w := range paneColorWrites { | |||
| 258 | + | sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w)) | |||
| 259 | + | } | |||
| 260 | + | d.paint(resumedSess, resumed, false, nil) | |||
| 261 | + | outs, data = after.newOutput(t) | |||
| 262 | + | require.NotEmpty(t, outs) | |||
| 263 | + | require.NotZero(t, outs[0].Base) | |||
| 264 | + | requireProfile(t, tt.to.Mode, data) | |||
| 265 | + | ||||
| 266 | + | if tt.checkToasts { | |||
| 267 | + | toasts, _ := visibleToasts(resumed) | |||
| 268 | + | var got []string | |||
| 269 | + | for _, toast := range toasts { | |||
| 270 | + | got = append(got, toast.Message) | |||
| 271 | + | } | |||
| 272 | + | require.Equal(t, tt.wantToasts, got) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Chrome styling follows the new mode as well. | |||
| 276 | + | reduced := tt.to.Mode != terminalcap.TrueColor | |||
| 277 | + | require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute) | |||
| 278 | + | }) | |||
| 279 | + | } | |||
| 280 | + | } | |||
M internal/usecase/daemon/attachment_output.go +25 -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 | |
| @@ -49,6 +50,16 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||
| 49 | 50 | return | |
| 50 | 51 | } | |
| 51 | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | |
| 53 | + | // The replacement terminal's color claim wins over whatever the previous | |
| 54 | + | // link negotiated: its renderer must encode for the terminal now attached. | |
| 55 | + | // Only the mode changes the encoding; the source is stored but never | |
| 56 | + | // forces a renderer swap. | |
| 57 | + | color := terminalcap.Resolve(h.Env, h.Color).Color | |
| 58 | + | swap := color.Mode != ac.terminalCapabilities.Color.Mode | |
| 59 | + | ac.terminalCapabilities.Color = color | |
| 60 | + | if swap { | |
| 61 | + | ac.output.setColorProfile(colorprofile.Profile(color)) | |
| 62 | + | } | |
| 52 | 63 | // A replacement terminal declares its own keyboard protocol. | |
| 53 | 64 | if ac.keys != nil { | |
| 54 | 65 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | |
| @@ -71,6 +82,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||
| 71 | 82 | ac.terminalCapabilities.KittyGraphics = false | |
| 72 | 83 | } | |
| 73 | 84 | ||
| 85 | + | // setColorProfile swaps in a renderer for profile. The next frame is a full | |
| 86 | + | // repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses | |
| 87 | + | // the profile change. | |
| 88 | + | func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) { | |
| 89 | + | if s == nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | s.lockView() | |
| 93 | + | defer s.unlockView() | |
| 94 | + | s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile) | |
| 95 | + | s.rebaseLocked() | |
| 96 | + | } | |
| 97 | + | ||
| 74 | 98 | // lockView serializes an attachment's view publication with every output | |
| 75 | 99 | // state transition. It deliberately does not take sendMu: callers that hold | |
| 76 | 100 | // architecture locks can publish a view without reversing the paint path's | |
| @@ -102,11 +126,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput { | |||
| 102 | 126 | } | |
| 103 | 127 | ||
| 104 | 128 | 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...) | |
| 129 | + | return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...) | |
| 110 | 130 | } | |
| 111 | 131 | ||
| 112 | 132 | 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 | ||
| @@ -49,6 +50,16 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||||
| 49 | return | 50 | return | ||
| 50 | } | 51 | } | ||
| 51 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | ||
| 53 | + | // The replacement terminal's color claim wins over whatever the previous | |||
| 54 | + | // link negotiated: its renderer must encode for the terminal now attached. | |||
| 55 | + | // Only the mode changes the encoding; the source is stored but never | |||
| 56 | + | // forces a renderer swap. | |||
| 57 | + | color := terminalcap.Resolve(h.Env, h.Color).Color | |||
| 58 | + | swap := color.Mode != ac.terminalCapabilities.Color.Mode | |||
| 59 | + | ac.terminalCapabilities.Color = color | |||
| 60 | + | if swap { | |||
| 61 | + | ac.output.setColorProfile(colorprofile.Profile(color)) | |||
| 62 | + | } | |||
| 52 | // A replacement terminal declares its own keyboard protocol. | 63 | // A replacement terminal declares its own keyboard protocol. | ||
| 53 | if ac.keys != nil { | 64 | if ac.keys != nil { | ||
| 54 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | 65 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | ||
| @@ -71,6 +82,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||||
| 71 | ac.terminalCapabilities.KittyGraphics = false | 82 | ac.terminalCapabilities.KittyGraphics = false | ||
| 72 | } | 83 | } | ||
| 73 | 84 | ||||
| 85 | + | // setColorProfile swaps in a renderer for profile. The next frame is a full | |||
| 86 | + | // repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses | |||
| 87 | + | // the profile change. | |||
| 88 | + | func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) { | |||
| 89 | + | if s == nil { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | s.lockView() | |||
| 93 | + | defer s.unlockView() | |||
| 94 | + | s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile) | |||
| 95 | + | s.rebaseLocked() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 74 | // lockView serializes an attachment's view publication with every output | 98 | // lockView serializes an attachment's view publication with every output | ||
| 75 | // state transition. It deliberately does not take sendMu: callers that hold | 99 | // state transition. It deliberately does not take sendMu: callers that hold | ||
| 76 | // architecture locks can publish a view without reversing the paint path's | 100 | // architecture locks can publish a view without reversing the paint path's | ||
| @@ -102,11 +126,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput { | |||||
| 102 | } | 126 | } | ||
| 103 | 127 | ||||
| 104 | func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput { | 128 | func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput { | ||
| 105 | - | profile := renderer.ColorProfileANSI256 | 129 | + | return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...) |
| 106 | - | if capabilities.TrueColor() { | |||
| 107 | - | profile = renderer.ColorProfileTrueColor | |||
| 108 | - | } | |||
| 109 | - | return newOutputStateStreamForProfile(profile, windowSize...) | |||
| 110 | } | 130 | } | ||
| 111 | 131 | ||||
| 112 | func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput { | 132 | 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 +41 -13
| @@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme { | |||
| 260 | 260 | if applied.Generation == 0 { | |
| 261 | 261 | // An unattached client has no terminal report yet. Reuse the static | |
| 262 | 262 | // neutral cache rather than resolving from a render path. | |
| 263 | - | applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles} | |
| 263 | + | applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)} | |
| 264 | 264 | } | |
| 265 | 265 | return applied | |
| 266 | 266 | } | |
| @@ -693,19 +693,46 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai | |||
| 693 | 693 | return ac, nil | |
| 694 | 694 | } | |
| 695 | 695 | ||
| 696 | + | // colorDowngradeNotice reports the toast shown when a declared (not forced) | |
| 697 | + | // terminal renders with fewer than TrueColor colors. A forced mode is a user | |
| 698 | + | // choice and never warrants a notice. | |
| 699 | + | func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) { | |
| 700 | + | if color.Source != terminalcap.SourceDeclared { | |
| 701 | + | return "", false | |
| 702 | + | } | |
| 703 | + | switch color.Mode { | |
| 704 | + | case terminalcap.ANSI256: | |
| 705 | + | return "Terminal supports 256 colors; vev UI colors are reduced.", true | |
| 706 | + | case terminalcap.ANSI16: | |
| 707 | + | return "Terminal supports 16 colors; vev UI colors are reduced.", true | |
| 708 | + | case terminalcap.Monochrome: | |
| 709 | + | return "Terminal has no color support; vev UI uses bold and reverse.", true | |
| 710 | + | default: | |
| 711 | + | return "", false | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // publishColorDowngradeNotice toasts ac when its terminal declared a reduced | |
| 716 | + | // color mode (see colorDowngradeNotice). | |
| 717 | + | func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) { | |
| 718 | + | message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color) | |
| 719 | + | if !ok { | |
| 720 | + | return | |
| 721 | + | } | |
| 722 | + | d.publishToast(ac, domain.Notification{ | |
| 723 | + | Code: domain.NoticeUser, | |
| 724 | + | Severity: domain.NoticeWarn, | |
| 725 | + | Message: message, | |
| 726 | + | Time: d.clock.Now(), | |
| 727 | + | Count: 1, | |
| 728 | + | SessionID: sess.id, | |
| 729 | + | }) | |
| 730 | + | } | |
| 731 | + | ||
| 696 | 732 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | |
| 697 | 733 | d.touchMRU(sess) | |
| 698 | 734 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | |
| 699 | - | if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() { | |
| 700 | - | d.publishToast(ac, domain.Notification{ | |
| 701 | - | Code: domain.NoticeUser, | |
| 702 | - | Severity: domain.NoticeWarn, | |
| 703 | - | Message: "TrueColor was not detected; rendering with 256 colors.", | |
| 704 | - | Time: d.clock.Now(), | |
| 705 | - | Count: 1, | |
| 706 | - | SessionID: sess.id, | |
| 707 | - | }) | |
| 708 | - | } | |
| 735 | + | d.publishColorDowngradeNotice(sess, ac) | |
| 709 | 736 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| 710 | 737 | if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) { | |
| 711 | 738 | d.warnUnsupportedGraphics(ac) | |
| @@ -721,7 +748,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 721 | 748 | resumeToken = d.nextResumeTokenLocked() | |
| 722 | 749 | } | |
| 723 | 750 | if !opts.capabilitiesSet { | |
| 724 | - | opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor} | |
| 751 | + | // The zero value is TrueColor: manually constructed attachments keep | |
| 752 | + | // the historical renderer behavior. | |
| 753 | + | opts.terminalCapabilities = terminalcap.Capabilities{} | |
| 725 | 754 | } | |
| 726 | 755 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | |
| 727 | 756 | geometry = geometry.NormalizePixels() | |
| @@ -945,7 +974,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme { | |||
| 945 | 974 | PaletteKnown: msg.PaletteKnown, | |
| 946 | 975 | HasFG: msg.HasForeground, | |
| 947 | 976 | HasBG: msg.HasBackground, | |
| 948 | - | TrueColor: msg.TrueColor, | |
| 949 | 977 | Known: msg.HasForeground && msg.HasBackground, | |
| 950 | 978 | SchemeKnown: msg.SchemeKnown, | |
| 951 | 979 | Light: msg.Light, | |
| @@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme { | |||||
| 260 | if applied.Generation == 0 { | 260 | if applied.Generation == 0 { | ||
| 261 | // An unattached client has no terminal report yet. Reuse the static | 261 | // An unattached client has no terminal report yet. Reuse the static | ||
| 262 | // neutral cache rather than resolving from a render path. | 262 | // neutral cache rather than resolving from a render path. | ||
| 263 | - | applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles} | 263 | + | applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)} |
| 264 | } | 264 | } | ||
| 265 | return applied | 265 | return applied | ||
| 266 | } | 266 | } | ||
| @@ -693,19 +693,46 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai | |||||
| 693 | return ac, nil | 693 | return ac, nil | ||
| 694 | } | 694 | } | ||
| 695 | 695 | ||||
| 696 | + | // colorDowngradeNotice reports the toast shown when a declared (not forced) | |||
| 697 | + | // terminal renders with fewer than TrueColor colors. A forced mode is a user | |||
| 698 | + | // choice and never warrants a notice. | |||
| 699 | + | func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) { | |||
| 700 | + | if color.Source != terminalcap.SourceDeclared { | |||
| 701 | + | return "", false | |||
| 702 | + | } | |||
| 703 | + | switch color.Mode { | |||
| 704 | + | case terminalcap.ANSI256: | |||
| 705 | + | return "Terminal supports 256 colors; vev UI colors are reduced.", true | |||
| 706 | + | case terminalcap.ANSI16: | |||
| 707 | + | return "Terminal supports 16 colors; vev UI colors are reduced.", true | |||
| 708 | + | case terminalcap.Monochrome: | |||
| 709 | + | return "Terminal has no color support; vev UI uses bold and reverse.", true | |||
| 710 | + | default: | |||
| 711 | + | return "", false | |||
| 712 | + | } | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | // publishColorDowngradeNotice toasts ac when its terminal declared a reduced | |||
| 716 | + | // color mode (see colorDowngradeNotice). | |||
| 717 | + | func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) { | |||
| 718 | + | message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color) | |||
| 719 | + | if !ok { | |||
| 720 | + | return | |||
| 721 | + | } | |||
| 722 | + | d.publishToast(ac, domain.Notification{ | |||
| 723 | + | Code: domain.NoticeUser, | |||
| 724 | + | Severity: domain.NoticeWarn, | |||
| 725 | + | Message: message, | |||
| 726 | + | Time: d.clock.Now(), | |||
| 727 | + | Count: 1, | |||
| 728 | + | SessionID: sess.id, | |||
| 729 | + | }) | |||
| 730 | + | } | |||
| 731 | + | ||||
| 696 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | 732 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | ||
| 697 | d.touchMRU(sess) | 733 | d.touchMRU(sess) | ||
| 698 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | 734 | d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable) | ||
| 699 | - | if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() { | 735 | + | d.publishColorDowngradeNotice(sess, ac) |
| 700 | - | d.publishToast(ac, domain.Notification{ | |||
| 701 | - | Code: domain.NoticeUser, | |||
| 702 | - | Severity: domain.NoticeWarn, | |||
| 703 | - | Message: "TrueColor was not detected; rendering with 256 colors.", | |||
| 704 | - | Time: d.clock.Now(), | |||
| 705 | - | Count: 1, | |||
| 706 | - | SessionID: sess.id, | |||
| 707 | - | }) | |||
| 708 | - | } | |||
| 709 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | 736 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | ||
| 710 | if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) { | 737 | if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) { | ||
| 711 | d.warnUnsupportedGraphics(ac) | 738 | d.warnUnsupportedGraphics(ac) | ||
| @@ -721,7 +748,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||||
| 721 | resumeToken = d.nextResumeTokenLocked() | 748 | resumeToken = d.nextResumeTokenLocked() | ||
| 722 | } | 749 | } | ||
| 723 | if !opts.capabilitiesSet { | 750 | if !opts.capabilitiesSet { | ||
| 724 | - | opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor} | 751 | + | // The zero value is TrueColor: manually constructed attachments keep |
| 752 | + | // the historical renderer behavior. | |||
| 753 | + | opts.terminalCapabilities = terminalcap.Capabilities{} | |||
| 725 | } | 754 | } | ||
| 726 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | 755 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | ||
| 727 | geometry = geometry.NormalizePixels() | 756 | geometry = geometry.NormalizePixels() | ||
| @@ -945,7 +974,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme { | |||||
| 945 | PaletteKnown: msg.PaletteKnown, | 974 | PaletteKnown: msg.PaletteKnown, | ||
| 946 | HasFG: msg.HasForeground, | 975 | HasFG: msg.HasForeground, | ||
| 947 | HasBG: msg.HasBackground, | 976 | HasBG: msg.HasBackground, | ||
| 948 | - | TrueColor: msg.TrueColor, | |||
| 949 | Known: msg.HasForeground && msg.HasBackground, | 977 | Known: msg.HasForeground && msg.HasBackground, | ||
| 950 | SchemeKnown: msg.SchemeKnown, | 978 | SchemeKnown: msg.SchemeKnown, | ||
| 951 | Light: msg.Light, | 979 | Light: msg.Light, | ||
A internal/usecase/daemon/color_notice_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestColorDowngradeNotice(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | color terminalcap.ColorCapabilities | |
| 15 | + | want string | |
| 16 | + | }{ | |
| 17 | + | {name: "declared 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, want: "Terminal supports 256 colors; vev UI colors are reduced."}, | |
| 18 | + | {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "Terminal supports 16 colors; vev UI colors are reduced."}, | |
| 19 | + | {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "Terminal has no color support; vev UI uses bold and reverse."}, | |
| 20 | + | {name: "declared truecolor", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |
| 21 | + | {name: "forced 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceForced}}, | |
| 22 | + | {name: "forced 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |
| 23 | + | {name: "forced monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}}, | |
| 24 | + | {name: "heuristic 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceHeuristic}}, | |
| 25 | + | {name: "unknown 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |
| 26 | + | {name: "zero value", color: terminalcap.ColorCapabilities{}}, | |
| 27 | + | } | |
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | got, ok := colorDowngradeNotice(tt.color) | |
| 31 | + | require.Equal(t, tt.want != "", ok) | |
| 32 | + | require.Equal(t, tt.want, got) | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TestFinishAttachedClientColorToast drives the real attach completion path: | |
| 38 | + | // only a declared reduced mode toasts, and a forced mode never does. | |
| 39 | + | func TestFinishAttachedClientColorToast(t *testing.T) { | |
| 40 | + | tests := []struct { | |
| 41 | + | name string | |
| 42 | + | color terminalcap.ColorCapabilities | |
| 43 | + | wantToast string | |
| 44 | + | }{ | |
| 45 | + | {name: "declared 256 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 256 colors; vev UI colors are reduced."}, | |
| 46 | + | {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 16 colors; vev UI colors are reduced."}, | |
| 47 | + | {name: "forced 16 colors is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |
| 48 | + | {name: "declared truecolor is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |
| 49 | + | } | |
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | pty, release := newBlockingPTY(t) | |
| 53 | + | defer release() | |
| 54 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 55 | + | ac.terminalCapabilities = terminalcap.Capabilities{Color: tt.color} | |
| 56 | + | ||
| 57 | + | d.finishAttachedClient(sess, ac, attachClientOptions{}) | |
| 58 | + | ||
| 59 | + | toasts, _ := visibleToasts(ac) | |
| 60 | + | if tt.wantToast == "" { | |
| 61 | + | require.Empty(t, toasts) | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | require.Len(t, toasts, 1) | |
| 65 | + | require.Equal(t, tt.wantToast, toasts[0].Message) | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestColorDowngradeNotice(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | color terminalcap.ColorCapabilities | |||
| 15 | + | want string | |||
| 16 | + | }{ | |||
| 17 | + | {name: "declared 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, want: "Terminal supports 256 colors; vev UI colors are reduced."}, | |||
| 18 | + | {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "Terminal supports 16 colors; vev UI colors are reduced."}, | |||
| 19 | + | {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "Terminal has no color support; vev UI uses bold and reverse."}, | |||
| 20 | + | {name: "declared truecolor", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |||
| 21 | + | {name: "forced 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceForced}}, | |||
| 22 | + | {name: "forced 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |||
| 23 | + | {name: "forced monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}}, | |||
| 24 | + | {name: "heuristic 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceHeuristic}}, | |||
| 25 | + | {name: "unknown 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, | |||
| 26 | + | {name: "zero value", color: terminalcap.ColorCapabilities{}}, | |||
| 27 | + | } | |||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | got, ok := colorDowngradeNotice(tt.color) | |||
| 31 | + | require.Equal(t, tt.want != "", ok) | |||
| 32 | + | require.Equal(t, tt.want, got) | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // TestFinishAttachedClientColorToast drives the real attach completion path: | |||
| 38 | + | // only a declared reduced mode toasts, and a forced mode never does. | |||
| 39 | + | func TestFinishAttachedClientColorToast(t *testing.T) { | |||
| 40 | + | tests := []struct { | |||
| 41 | + | name string | |||
| 42 | + | color terminalcap.ColorCapabilities | |||
| 43 | + | wantToast string | |||
| 44 | + | }{ | |||
| 45 | + | {name: "declared 256 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 256 colors; vev UI colors are reduced."}, | |||
| 46 | + | {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 16 colors; vev UI colors are reduced."}, | |||
| 47 | + | {name: "forced 16 colors is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, | |||
| 48 | + | {name: "declared truecolor is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, | |||
| 49 | + | } | |||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | + | pty, release := newBlockingPTY(t) | |||
| 53 | + | defer release() | |||
| 54 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 55 | + | ac.terminalCapabilities = terminalcap.Capabilities{Color: tt.color} | |||
| 56 | + | ||||
| 57 | + | d.finishAttachedClient(sess, ac, attachClientOptions{}) | |||
| 58 | + | ||||
| 59 | + | toasts, _ := visibleToasts(ac) | |||
| 60 | + | if tt.wantToast == "" { | |||
| 61 | + | require.Empty(t, toasts) | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | require.Len(t, toasts, 1) | |||
| 65 | + | require.Equal(t, tt.wantToast, toasts[0].Message) | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
M internal/usecase/daemon/config.go +7 -2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/command" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||
| 344 | 345 | return effectiveThemeForConfig(clientTheme, d.currentThemeConfig()) | |
| 345 | 346 | } | |
| 346 | 347 | ||
| 347 | - | func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | |
| 348 | + | // resolveAppliedTheme resolves chrome for one attachment's color capability; | |
| 349 | + | // ANSI16 and monochrome attachments get attribute-based chrome, so the result | |
| 350 | + | // is never shared between attachments of different modes. | |
| 351 | + | func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme { | |
| 348 | 352 | config := d.currentThemeConfig() | |
| 349 | 353 | effective := effectiveThemeForConfig(raw, config) | |
| 350 | - | return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)} | |
| 354 | + | resolved := theme.ResolveForColor(effective, config.accent, color) | |
| 355 | + | return appliedTheme{Raw: resolved.Theme, Resolved: resolved} | |
| 351 | 356 | } | |
| 352 | 357 | ||
| 353 | 358 | func (d *Daemon) reapplyThemeAllSessions() { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 13 | "github.com/bnema/vev/internal/usecase/command" | 14 | "github.com/bnema/vev/internal/usecase/command" | ||
| 14 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 15 | "github.com/bnema/vev/internal/usecase/theme" | 16 | "github.com/bnema/vev/internal/usecase/theme" | ||
| @@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||||
| 344 | return effectiveThemeForConfig(clientTheme, d.currentThemeConfig()) | 345 | return effectiveThemeForConfig(clientTheme, d.currentThemeConfig()) | ||
| 345 | } | 346 | } | ||
| 346 | 347 | ||||
| 347 | - | func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | 348 | + | // resolveAppliedTheme resolves chrome for one attachment's color capability; |
| 349 | + | // ANSI16 and monochrome attachments get attribute-based chrome, so the result | |||
| 350 | + | // is never shared between attachments of different modes. | |||
| 351 | + | func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme { | |||
| 348 | config := d.currentThemeConfig() | 352 | config := d.currentThemeConfig() | ||
| 349 | effective := effectiveThemeForConfig(raw, config) | 353 | effective := effectiveThemeForConfig(raw, config) | ||
| 350 | - | return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)} | 354 | + | resolved := theme.ResolveForColor(effective, config.accent, color) |
| 355 | + | return appliedTheme{Raw: resolved.Theme, Resolved: resolved} | |||
| 351 | } | 356 | } | ||
| 352 | 357 | ||||
| 353 | func (d *Daemon) reapplyThemeAllSessions() { | 358 | func (d *Daemon) reapplyThemeAllSessions() { | ||
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.Color) | |
| 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.Color) |
| 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/floating_render_test.go +0 -1
| @@ -737,7 +737,6 @@ func benchmarkCapturedRenderState() capturedRenderState { | |||
| 737 | 737 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | |
| 738 | 738 | HasFG: true, | |
| 739 | 739 | HasBG: true, | |
| 740 | - | TrueColor: true, | |
| 741 | 740 | Known: true, | |
| 742 | 741 | SchemeKnown: true, | |
| 743 | 742 | UsePalette: true, | |
| @@ -737,7 +737,6 @@ func benchmarkCapturedRenderState() capturedRenderState { | |||||
| 737 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | 737 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | ||
| 738 | HasFG: true, | 738 | HasFG: true, | ||
| 739 | HasBG: true, | 739 | HasBG: true, | ||
| 740 | - | TrueColor: true, | |||
| 741 | Known: true, | 740 | Known: true, | ||
| 742 | SchemeKnown: true, | 741 | SchemeKnown: true, | ||
| 743 | UsePalette: true, | 742 | UsePalette: true, | ||
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() | ||
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func backdropTheme() themeui.Theme { | |
| 14 | - | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 14 | + | return themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func backdropTheme() themeui.Theme { | 13 | func backdropTheme() themeui.Theme { | ||
| 14 | - | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | 14 | + | return themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} |
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | 17 | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | ||
M internal/usecase/daemon/palette_test.go +3 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | renderer "github.com/bnema/vev-vt" | |
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -131,11 +132,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 131 | 132 | paletteColors[10] = paletteColors[2] | |
| 132 | 133 | accentTheme := themeui.Theme{ | |
| 133 | 134 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 134 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 135 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, | |
| 135 | 136 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 136 | 137 | } | |
| 137 | 138 | indexedTheme := accentTheme | |
| 138 | - | indexedTheme.TrueColor = false | |
| 139 | 139 | neutralTheme := accentTheme | |
| 140 | 140 | neutralTheme.PaletteKnown = 0 | |
| 141 | 141 | neutralTheme.SchemeKnown = false | |
| @@ -164,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 164 | 164 | t.Run(tt.name, func(t *testing.T) { | |
| 165 | 165 | d := newTestDaemon(t, nil, stubClock{}) | |
| 166 | 166 | d.ApplyConfig(tt.config) | |
| 167 | - | applied := d.resolveAppliedTheme(tt.raw) | |
| 167 | + | applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{}) | |
| 168 | 168 | model := palette.New(palette.CommandResults([]command.Command{ | |
| 169 | 169 | {Code: "ONE", Name: "One", Desc: "first description"}, | |
| 170 | 170 | {Code: "TWO", Name: "Two", Desc: "second description"}, | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | renderer "github.com/bnema/vev-vt" | 11 | renderer "github.com/bnema/vev-vt" | ||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 14 | "github.com/bnema/vev/internal/protocol" | 15 | "github.com/bnema/vev/internal/protocol" | ||
| 15 | "github.com/bnema/vev/internal/usecase/command" | 16 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -131,11 +132,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||||
| 131 | paletteColors[10] = paletteColors[2] | 132 | paletteColors[10] = paletteColors[2] | ||
| 132 | accentTheme := themeui.Theme{ | 133 | accentTheme := themeui.Theme{ | ||
| 133 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | 134 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | ||
| 134 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | 135 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, |
| 135 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | 136 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | ||
| 136 | } | 137 | } | ||
| 137 | indexedTheme := accentTheme | 138 | indexedTheme := accentTheme | ||
| 138 | - | indexedTheme.TrueColor = false | |||
| 139 | neutralTheme := accentTheme | 139 | neutralTheme := accentTheme | ||
| 140 | neutralTheme.PaletteKnown = 0 | 140 | neutralTheme.PaletteKnown = 0 | ||
| 141 | neutralTheme.SchemeKnown = false | 141 | neutralTheme.SchemeKnown = false | ||
| @@ -164,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||||
| 164 | t.Run(tt.name, func(t *testing.T) { | 164 | t.Run(tt.name, func(t *testing.T) { | ||
| 165 | d := newTestDaemon(t, nil, stubClock{}) | 165 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 166 | d.ApplyConfig(tt.config) | 166 | d.ApplyConfig(tt.config) | ||
| 167 | - | applied := d.resolveAppliedTheme(tt.raw) | 167 | + | applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{}) |
| 168 | model := palette.New(palette.CommandResults([]command.Command{ | 168 | model := palette.New(palette.CommandResults([]command.Command{ | ||
| 169 | {Code: "ONE", Name: "One", Desc: "first description"}, | 169 | {Code: "ONE", Name: "One", Desc: "first description"}, | ||
| 170 | {Code: "TWO", Name: "Two", Desc: "second description"}, | 170 | {Code: "TWO", Name: "Two", Desc: "second description"}, | ||
A internal/usecase/daemon/reduced_color_test.go +242 -0
| @@ -0,0 +1,242 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | renderer "github.com/bnema/vev-vt" | |
| 12 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 15 | + | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m") | |
| 23 | + | ||
| 24 | + | // colorParams returns every color-bearing SGR parameter group in out, e.g. | |
| 25 | + | // "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are | |
| 26 | + | // not reported. | |
| 27 | + | func colorParams(out []byte) []string { | |
| 28 | + | var found []string | |
| 29 | + | for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) { | |
| 30 | + | params := strings.Split(m[1], ";") | |
| 31 | + | for i := 0; i < len(params); i++ { | |
| 32 | + | n, err := strconv.Atoi(params[i]) | |
| 33 | + | if err != nil { | |
| 34 | + | continue | |
| 35 | + | } | |
| 36 | + | switch { | |
| 37 | + | case n == 38 || n == 48 || n == 58: | |
| 38 | + | end := len(params) | |
| 39 | + | if i+1 < len(params) && params[i+1] == "2" { | |
| 40 | + | end = min(i+5, len(params)) | |
| 41 | + | } else if i+1 < len(params) { | |
| 42 | + | end = min(i+3, len(params)) | |
| 43 | + | } | |
| 44 | + | found = append(found, strings.Join(params[i:end], ";")) | |
| 45 | + | i = end - 1 | |
| 46 | + | case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107: | |
| 47 | + | found = append(found, params[i]) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | return found | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func reducedTestTheme() themeui.Theme { | |
| 55 | + | palette := [16]renderer.RGB{} | |
| 56 | + | palette[4] = renderer.RGB{R: 60, G: 120, B: 220} | |
| 57 | + | palette[12] = palette[4] | |
| 58 | + | return themeui.Theme{ | |
| 59 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 60 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true, | |
| 61 | + | Palette: palette, PaletteKnown: 1<<4 | 1<<12, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // reducedChromeFrame composes a representative frame: two tabs (second | |
| 66 | + | // active) with an attention bell, a split with the left pane focused, a | |
| 67 | + | // bottom bar with MRU entries, a toast, and a focused palette modal with a | |
| 68 | + | // selected row. rawTheme is what the attachment reported. | |
| 69 | + | func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) { | |
| 70 | + | t.Helper() | |
| 71 | + | resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color) | |
| 72 | + | state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme) | |
| 73 | + | state.styles = resolved.Styles | |
| 74 | + | state.bars = barState{ | |
| 75 | + | status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}}, | |
| 76 | + | attentionFrame: pulseFrameCount / 2, | |
| 77 | + | mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}}, | |
| 78 | + | theme: resolved.Theme, | |
| 79 | + | } | |
| 80 | + | state.panes[0].title, state.panes[1].title = "left", "right" | |
| 81 | + | state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}} | |
| 82 | + | inner := renderer.NewFrame(18, 3) | |
| 83 | + | for x, r := range "> sel" { | |
| 84 | + | inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection}) | |
| 85 | + | } | |
| 86 | + | state.overlays.paletteActive = true | |
| 87 | + | state.overlays.palette = capturedModal{ | |
| 88 | + | active: true, title: "Palette", focused: true, inner: inner, | |
| 89 | + | presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll}, | |
| 90 | + | } | |
| 91 | + | return composeFrame(state, composeCacheInput{}), resolved | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // rowIndex returns the x of the first cell of text on row y. | |
| 95 | + | func rowIndex(frame renderer.Frame, y int, text string) int { | |
| 96 | + | var row strings.Builder | |
| 97 | + | for x := range frame.Width { | |
| 98 | + | r := frame.At(x, y).Rune | |
| 99 | + | if r == 0 { | |
| 100 | + | r = ' ' | |
| 101 | + | } | |
| 102 | + | row.WriteRune(r) | |
| 103 | + | } | |
| 104 | + | return strings.Index(row.String(), text) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte { | |
| 108 | + | t.Helper() | |
| 109 | + | out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()}) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | return out | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestReducedColorChromeIsLegible(t *testing.T) { | |
| 115 | + | ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |
| 116 | + | mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |
| 117 | + | ||
| 118 | + | t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) { | |
| 119 | + | composed, resolved := reducedChromeFrame(t, ansi16) | |
| 120 | + | out := drawProfile(t, composed.frame, ansi16) | |
| 121 | + | for _, p := range colorParams(out) { | |
| 122 | + | require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p) | |
| 123 | + | } | |
| 124 | + | // The accent is a palette slot, never RGB: the border and bell use it. | |
| 125 | + | require.True(t, resolved.Accent.Known) | |
| 126 | + | require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue") | |
| 127 | + | // Chrome surfaces are the terminal background: no RGB backgrounds. | |
| 128 | + | for x := range composed.frame.Width { | |
| 129 | + | for _, y := range []int{0, composed.frame.Height - 1} { | |
| 130 | + | require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | ||
| 135 | + | t.Run("monochrome has no color sequences at all", func(t *testing.T) { | |
| 136 | + | composed, _ := reducedChromeFrame(t, mono) | |
| 137 | + | out := drawProfile(t, composed.frame, mono) | |
| 138 | + | require.Empty(t, colorParams(out), "%q", out) | |
| 139 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out)) | |
| 140 | + | }) | |
| 141 | + | ||
| 142 | + | for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} { | |
| 143 | + | t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) { | |
| 144 | + | composed, resolved := reducedChromeFrame(t, color) | |
| 145 | + | frame := composed.frame | |
| 146 | + | // Top bar: " one" (inactive) vs " two" (active). | |
| 147 | + | inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style | |
| 148 | + | require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse") | |
| 149 | + | require.False(t, inactive.Inverse) | |
| 150 | + | require.NotEqual(t, inactive.Canonical(), active.Canonical()) | |
| 151 | + | // The attention bell is bold on its visible beat. | |
| 152 | + | bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style | |
| 153 | + | require.True(t, bell.Bold && bell.Inverse) | |
| 154 | + | // Bottom bar: the session name is the active surface. | |
| 155 | + | require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse) | |
| 156 | + | require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse) | |
| 157 | + | // Focused pane title bar is plain reverse (no stack title bars in | |
| 158 | + | // this fixture, so assert the role the title bar draws with). | |
| 159 | + | require.True(t, resolved.Styles.StatusBar.Inverse) | |
| 160 | + | // Palette selection row and focused modal border. | |
| 161 | + | require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse") | |
| 162 | + | border := frame.At(10, 2).Style | |
| 163 | + | require.True(t, border.Bold, "focused modal border must be bold") | |
| 164 | + | require.Zero(t, border.Attrs&ansirenderer.AttrDim) | |
| 165 | + | // The same border, unfocused, is faint: focus is attribute-visible. | |
| 166 | + | require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim) | |
| 167 | + | // Copy selection is plain reverse. | |
| 168 | + | require.True(t, resolved.Styles.Selection.Inverse) | |
| 169 | + | require.True(t, resolved.Styles.CopyStatus.Inverse) | |
| 170 | + | // Toast box: warn border is visible without color. | |
| 171 | + | require.True(t, resolved.Styles.BorderWarn.Bold) | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // TestReducedColorLeavesPaneContentAlone guards that panes are never recolored | |
| 177 | + | // or faded: vev-vt quantizes them, vev chrome must not touch them. | |
| 178 | + | func TestReducedColorLeavesPaneContentAlone(t *testing.T) { | |
| 179 | + | for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} { | |
| 180 | + | color := terminalcap.ColorCapabilities{Mode: mode} | |
| 181 | + | resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color) | |
| 182 | + | state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme) | |
| 183 | + | state.styles = resolved.Styles | |
| 184 | + | pane := state.panes[1].frame // unfocused | |
| 185 | + | cell := pane.At(0, 0) | |
| 186 | + | cell.Style.Foreground = 196 | |
| 187 | + | pane.Set(0, 0, cell) | |
| 188 | + | composed := composeFrame(state, composeCacheInput{}) | |
| 189 | + | x := state.panes[1].placement.Content.X | |
| 190 | + | y := state.panes[1].placement.Content.Y + 1 | |
| 191 | + | require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical()) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256 | |
| 196 | + | // attachments resolve exactly as before this policy existed, so their output | |
| 197 | + | // bytes cannot change. | |
| 198 | + | func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) { | |
| 199 | + | raw := reducedTestTheme() | |
| 200 | + | policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto} | |
| 201 | + | want := themeui.Resolve(raw, policy) | |
| 202 | + | for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} { | |
| 203 | + | require.Equal(t, want, themeui.ResolveForColor(raw, policy, color)) | |
| 204 | + | } | |
| 205 | + | for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} { | |
| 206 | + | composed, _ := reducedChromeFrame(t, color) | |
| 207 | + | state := cachedSplitState("reference", "left", layout.Horizontal, raw) | |
| 208 | + | _ = state | |
| 209 | + | out := drawProfile(t, composed.frame, color) | |
| 210 | + | require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces") | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // TestAppliedThemeIsPerAttachment confirms two attachments of one session | |
| 215 | + | // resolve independently. | |
| 216 | + | func TestAppliedThemeIsPerAttachment(t *testing.T) { | |
| 217 | + | p, release := newBlockingPTY(t) | |
| 218 | + | defer release() | |
| 219 | + | d, sess, ac16, _ := newManualSessionWithPTYs(t, p) | |
| 220 | + | ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}} | |
| 221 | + | tr, _ := newCapturingTransport(t) | |
| 222 | + | acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 223 | + | acTrue.output.attachment = acTrue | |
| 224 | + | acTrue.initOverlays() | |
| 225 | + | sess.mu.Lock() | |
| 226 | + | sess.attachments[acTrue] = struct{}{} | |
| 227 | + | sess.mu.Unlock() | |
| 228 | + | acTrue.setSession(sess) | |
| 229 | + | ||
| 230 | + | raw := reducedTestTheme() | |
| 231 | + | msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown} | |
| 232 | + | d.applyTheme(sess, ac16, msg) | |
| 233 | + | d.applyTheme(sess, acTrue, msg) | |
| 234 | + | d.reapplyThemeSession(sess) // a config reload must keep each mode | |
| 235 | + | ||
| 236 | + | reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme() | |
| 237 | + | require.True(t, reduced.Raw.DimByAttribute) | |
| 238 | + | require.False(t, rgb.Raw.DimByAttribute) | |
| 239 | + | require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB) | |
| 240 | + | require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB) | |
| 241 | + | require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles) | |
| 242 | + | } | |
| @@ -0,0 +1,242 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | renderer "github.com/bnema/vev-vt" | |||
| 12 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol" | |||
| 16 | + | "github.com/bnema/vev/internal/usecase/colorprofile" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 18 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 19 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m") | |||
| 23 | + | ||||
| 24 | + | // colorParams returns every color-bearing SGR parameter group in out, e.g. | |||
| 25 | + | // "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are | |||
| 26 | + | // not reported. | |||
| 27 | + | func colorParams(out []byte) []string { | |||
| 28 | + | var found []string | |||
| 29 | + | for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) { | |||
| 30 | + | params := strings.Split(m[1], ";") | |||
| 31 | + | for i := 0; i < len(params); i++ { | |||
| 32 | + | n, err := strconv.Atoi(params[i]) | |||
| 33 | + | if err != nil { | |||
| 34 | + | continue | |||
| 35 | + | } | |||
| 36 | + | switch { | |||
| 37 | + | case n == 38 || n == 48 || n == 58: | |||
| 38 | + | end := len(params) | |||
| 39 | + | if i+1 < len(params) && params[i+1] == "2" { | |||
| 40 | + | end = min(i+5, len(params)) | |||
| 41 | + | } else if i+1 < len(params) { | |||
| 42 | + | end = min(i+3, len(params)) | |||
| 43 | + | } | |||
| 44 | + | found = append(found, strings.Join(params[i:end], ";")) | |||
| 45 | + | i = end - 1 | |||
| 46 | + | case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107: | |||
| 47 | + | found = append(found, params[i]) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | return found | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func reducedTestTheme() themeui.Theme { | |||
| 55 | + | palette := [16]renderer.RGB{} | |||
| 56 | + | palette[4] = renderer.RGB{R: 60, G: 120, B: 220} | |||
| 57 | + | palette[12] = palette[4] | |||
| 58 | + | return themeui.Theme{ | |||
| 59 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 60 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true, | |||
| 61 | + | Palette: palette, PaletteKnown: 1<<4 | 1<<12, | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // reducedChromeFrame composes a representative frame: two tabs (second | |||
| 66 | + | // active) with an attention bell, a split with the left pane focused, a | |||
| 67 | + | // bottom bar with MRU entries, a toast, and a focused palette modal with a | |||
| 68 | + | // selected row. rawTheme is what the attachment reported. | |||
| 69 | + | func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) { | |||
| 70 | + | t.Helper() | |||
| 71 | + | resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color) | |||
| 72 | + | state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme) | |||
| 73 | + | state.styles = resolved.Styles | |||
| 74 | + | state.bars = barState{ | |||
| 75 | + | status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}}, | |||
| 76 | + | attentionFrame: pulseFrameCount / 2, | |||
| 77 | + | mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}}, | |||
| 78 | + | theme: resolved.Theme, | |||
| 79 | + | } | |||
| 80 | + | state.panes[0].title, state.panes[1].title = "left", "right" | |||
| 81 | + | state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}} | |||
| 82 | + | inner := renderer.NewFrame(18, 3) | |||
| 83 | + | for x, r := range "> sel" { | |||
| 84 | + | inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection}) | |||
| 85 | + | } | |||
| 86 | + | state.overlays.paletteActive = true | |||
| 87 | + | state.overlays.palette = capturedModal{ | |||
| 88 | + | active: true, title: "Palette", focused: true, inner: inner, | |||
| 89 | + | presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll}, | |||
| 90 | + | } | |||
| 91 | + | return composeFrame(state, composeCacheInput{}), resolved | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // rowIndex returns the x of the first cell of text on row y. | |||
| 95 | + | func rowIndex(frame renderer.Frame, y int, text string) int { | |||
| 96 | + | var row strings.Builder | |||
| 97 | + | for x := range frame.Width { | |||
| 98 | + | r := frame.At(x, y).Rune | |||
| 99 | + | if r == 0 { | |||
| 100 | + | r = ' ' | |||
| 101 | + | } | |||
| 102 | + | row.WriteRune(r) | |||
| 103 | + | } | |||
| 104 | + | return strings.Index(row.String(), text) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte { | |||
| 108 | + | t.Helper() | |||
| 109 | + | out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()}) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | return out | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestReducedColorChromeIsLegible(t *testing.T) { | |||
| 115 | + | ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |||
| 116 | + | mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |||
| 117 | + | ||||
| 118 | + | t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) { | |||
| 119 | + | composed, resolved := reducedChromeFrame(t, ansi16) | |||
| 120 | + | out := drawProfile(t, composed.frame, ansi16) | |||
| 121 | + | for _, p := range colorParams(out) { | |||
| 122 | + | require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p) | |||
| 123 | + | } | |||
| 124 | + | // The accent is a palette slot, never RGB: the border and bell use it. | |||
| 125 | + | require.True(t, resolved.Accent.Known) | |||
| 126 | + | require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue") | |||
| 127 | + | // Chrome surfaces are the terminal background: no RGB backgrounds. | |||
| 128 | + | for x := range composed.frame.Width { | |||
| 129 | + | for _, y := range []int{0, composed.frame.Height - 1} { | |||
| 130 | + | require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | ||||
| 135 | + | t.Run("monochrome has no color sequences at all", func(t *testing.T) { | |||
| 136 | + | composed, _ := reducedChromeFrame(t, mono) | |||
| 137 | + | out := drawProfile(t, composed.frame, mono) | |||
| 138 | + | require.Empty(t, colorParams(out), "%q", out) | |||
| 139 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out)) | |||
| 140 | + | }) | |||
| 141 | + | ||||
| 142 | + | for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} { | |||
| 143 | + | t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) { | |||
| 144 | + | composed, resolved := reducedChromeFrame(t, color) | |||
| 145 | + | frame := composed.frame | |||
| 146 | + | // Top bar: " one" (inactive) vs " two" (active). | |||
| 147 | + | inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style | |||
| 148 | + | require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse") | |||
| 149 | + | require.False(t, inactive.Inverse) | |||
| 150 | + | require.NotEqual(t, inactive.Canonical(), active.Canonical()) | |||
| 151 | + | // The attention bell is bold on its visible beat. | |||
| 152 | + | bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style | |||
| 153 | + | require.True(t, bell.Bold && bell.Inverse) | |||
| 154 | + | // Bottom bar: the session name is the active surface. | |||
| 155 | + | require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse) | |||
| 156 | + | require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse) | |||
| 157 | + | // Focused pane title bar is plain reverse (no stack title bars in | |||
| 158 | + | // this fixture, so assert the role the title bar draws with). | |||
| 159 | + | require.True(t, resolved.Styles.StatusBar.Inverse) | |||
| 160 | + | // Palette selection row and focused modal border. | |||
| 161 | + | require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse") | |||
| 162 | + | border := frame.At(10, 2).Style | |||
| 163 | + | require.True(t, border.Bold, "focused modal border must be bold") | |||
| 164 | + | require.Zero(t, border.Attrs&ansirenderer.AttrDim) | |||
| 165 | + | // The same border, unfocused, is faint: focus is attribute-visible. | |||
| 166 | + | require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim) | |||
| 167 | + | // Copy selection is plain reverse. | |||
| 168 | + | require.True(t, resolved.Styles.Selection.Inverse) | |||
| 169 | + | require.True(t, resolved.Styles.CopyStatus.Inverse) | |||
| 170 | + | // Toast box: warn border is visible without color. | |||
| 171 | + | require.True(t, resolved.Styles.BorderWarn.Bold) | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // TestReducedColorLeavesPaneContentAlone guards that panes are never recolored | |||
| 177 | + | // or faded: vev-vt quantizes them, vev chrome must not touch them. | |||
| 178 | + | func TestReducedColorLeavesPaneContentAlone(t *testing.T) { | |||
| 179 | + | for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} { | |||
| 180 | + | color := terminalcap.ColorCapabilities{Mode: mode} | |||
| 181 | + | resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color) | |||
| 182 | + | state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme) | |||
| 183 | + | state.styles = resolved.Styles | |||
| 184 | + | pane := state.panes[1].frame // unfocused | |||
| 185 | + | cell := pane.At(0, 0) | |||
| 186 | + | cell.Style.Foreground = 196 | |||
| 187 | + | pane.Set(0, 0, cell) | |||
| 188 | + | composed := composeFrame(state, composeCacheInput{}) | |||
| 189 | + | x := state.panes[1].placement.Content.X | |||
| 190 | + | y := state.panes[1].placement.Content.Y + 1 | |||
| 191 | + | require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical()) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256 | |||
| 196 | + | // attachments resolve exactly as before this policy existed, so their output | |||
| 197 | + | // bytes cannot change. | |||
| 198 | + | func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) { | |||
| 199 | + | raw := reducedTestTheme() | |||
| 200 | + | policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto} | |||
| 201 | + | want := themeui.Resolve(raw, policy) | |||
| 202 | + | for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} { | |||
| 203 | + | require.Equal(t, want, themeui.ResolveForColor(raw, policy, color)) | |||
| 204 | + | } | |||
| 205 | + | for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} { | |||
| 206 | + | composed, _ := reducedChromeFrame(t, color) | |||
| 207 | + | state := cachedSplitState("reference", "left", layout.Horizontal, raw) | |||
| 208 | + | _ = state | |||
| 209 | + | out := drawProfile(t, composed.frame, color) | |||
| 210 | + | require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces") | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // TestAppliedThemeIsPerAttachment confirms two attachments of one session | |||
| 215 | + | // resolve independently. | |||
| 216 | + | func TestAppliedThemeIsPerAttachment(t *testing.T) { | |||
| 217 | + | p, release := newBlockingPTY(t) | |||
| 218 | + | defer release() | |||
| 219 | + | d, sess, ac16, _ := newManualSessionWithPTYs(t, p) | |||
| 220 | + | ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}} | |||
| 221 | + | tr, _ := newCapturingTransport(t) | |||
| 222 | + | acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 223 | + | acTrue.output.attachment = acTrue | |||
| 224 | + | acTrue.initOverlays() | |||
| 225 | + | sess.mu.Lock() | |||
| 226 | + | sess.attachments[acTrue] = struct{}{} | |||
| 227 | + | sess.mu.Unlock() | |||
| 228 | + | acTrue.setSession(sess) | |||
| 229 | + | ||||
| 230 | + | raw := reducedTestTheme() | |||
| 231 | + | msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown} | |||
| 232 | + | d.applyTheme(sess, ac16, msg) | |||
| 233 | + | d.applyTheme(sess, acTrue, msg) | |||
| 234 | + | d.reapplyThemeSession(sess) // a config reload must keep each mode | |||
| 235 | + | ||||
| 236 | + | reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme() | |||
| 237 | + | require.True(t, reduced.Raw.DimByAttribute) | |||
| 238 | + | require.False(t, rgb.Raw.DimByAttribute) | |||
| 239 | + | require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB) | |||
| 240 | + | require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB) | |||
| 241 | + | require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles) | |||
| 242 | + | } | |||
M internal/usecase/daemon/render.go +18 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | renderer "github.com/bnema/vev-vt/ansi" | |
| 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/usecase/layout" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset | |||
| 496 | 497 | ||
| 497 | 498 | var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 498 | 499 | ||
| 500 | + | var ( | |
| 501 | + | fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles | |
| 502 | + | fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles | |
| 503 | + | ) | |
| 504 | + | ||
| 505 | + | // fallbackStylesFor is the static pre-theme chrome for one attachment's mode. | |
| 506 | + | func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles { | |
| 507 | + | switch color.Mode { | |
| 508 | + | case terminalcap.ANSI16: | |
| 509 | + | return fallbackANSI16Styles | |
| 510 | + | case terminalcap.Monochrome: | |
| 511 | + | return fallbackMonoStyles | |
| 512 | + | default: | |
| 513 | + | return fallbackChromeStyles | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | ||
| 499 | 517 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | |
| 500 | 518 | if len(styles) > 0 { | |
| 501 | 519 | return styles[0] | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | renderer "github.com/bnema/vev-vt/ansi" | 7 | renderer "github.com/bnema/vev-vt/ansi" | ||
| 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/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| @@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset | |||||
| 496 | 497 | ||||
| 497 | var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | 498 | var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | ||
| 498 | 499 | ||||
| 500 | + | var ( | |||
| 501 | + | fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles | |||
| 502 | + | fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles | |||
| 503 | + | ) | |||
| 504 | + | ||||
| 505 | + | // fallbackStylesFor is the static pre-theme chrome for one attachment's mode. | |||
| 506 | + | func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles { | |||
| 507 | + | switch color.Mode { | |||
| 508 | + | case terminalcap.ANSI16: | |||
| 509 | + | return fallbackANSI16Styles | |||
| 510 | + | case terminalcap.Monochrome: | |||
| 511 | + | return fallbackMonoStyles | |||
| 512 | + | default: | |||
| 513 | + | return fallbackChromeStyles | |||
| 514 | + | } | |||
| 515 | + | } | |||
| 516 | + | ||||
| 499 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | 517 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | ||
| 500 | if len(styles) > 0 { | 518 | if len(styles) > 0 { | ||
| 501 | return styles[0] | 519 | return styles[0] | ||
M internal/usecase/daemon/render_coordinator_test.go +1 -1
| @@ -1221,7 +1221,7 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1221 | 1221 | file: "client.go", | |
| 1222 | 1222 | name: "client theme application", | |
| 1223 | 1223 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1224 | - | d.applyTheme(sess, ac, protocol.Theme{TrueColor: true}) | |
| 1224 | + | d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true}) | |
| 1225 | 1225 | }, | |
| 1226 | 1226 | }, | |
| 1227 | 1227 | { | |
| @@ -1221,7 +1221,7 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1221 | file: "client.go", | 1221 | file: "client.go", | ||
| 1222 | name: "client theme application", | 1222 | name: "client theme application", | ||
| 1223 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | 1223 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | ||
| 1224 | - | d.applyTheme(sess, ac, protocol.Theme{TrueColor: true}) | 1224 | + | d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true}) |
| 1225 | }, | 1225 | }, | ||
| 1226 | }, | 1226 | }, | ||
| 1227 | { | 1227 | { | ||
M internal/usecase/daemon/render_pipeline.go +3 -1
| @@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 157 | 157 | titles[pane.id] = pane.titleGeneration | |
| 158 | 158 | } | |
| 159 | 159 | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { | |
| 160 | - | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer) | |
| 160 | + | // Reduced-color chrome never recolors or fades pane content; focus is | |
| 161 | + | // shown by the title bar and dividers. | |
| 162 | + | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer) | |
| 161 | 163 | } | |
| 162 | 164 | for _, d := range pane.damage { | |
| 163 | 165 | if d.Kind != renderer.DamageFullRedraw { | |
| @@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 157 | titles[pane.id] = pane.titleGeneration | 157 | titles[pane.id] = pane.titleGeneration | ||
| 158 | } | 158 | } | ||
| 159 | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { | 159 | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { | ||
| 160 | - | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer) | 160 | + | // Reduced-color chrome never recolors or fades pane content; focus is |
| 161 | + | // shown by the title bar and dividers. | |||
| 162 | + | blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer) | |||
| 161 | } | 163 | } | ||
| 162 | for _, d := range pane.damage { | 164 | for _, d := range pane.damage { | ||
| 163 | if d.Kind != renderer.DamageFullRedraw { | 165 | if d.Kind != renderer.DamageFullRedraw { | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -3
| @@ -94,7 +94,7 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |||
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) { | |
| 97 | - | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 97 | + | theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 98 | 98 | initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |
| 99 | 99 | committed := composeFrame(initial, composeCacheInput{}) | |
| 100 | 100 | require.Equal(t, '│', committed.frame.At(20, 1).Rune) | |
| @@ -170,7 +170,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |||
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | 172 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |
| 173 | - | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |
| 173 | + | theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |
| 174 | 174 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | |
| 175 | 175 | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | |
| 176 | 176 | ||
| @@ -197,7 +197,6 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 197 | 197 | state := cachedStackTitleState("collapsed", 1, true) | |
| 198 | 198 | state.theme = themeui.Theme{ | |
| 199 | 199 | Known: true, | |
| 200 | - | TrueColor: true, | |
| 201 | 200 | HasFG: true, | |
| 202 | 201 | HasBG: true, | |
| 203 | 202 | Foreground: renderer.RGB{R: 220, G: 210, B: 200}, | |
| @@ -94,7 +94,7 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) { | |||||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) { | 96 | func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) { | ||
| 97 | - | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | 97 | + | theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} |
| 98 | initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | 98 | initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | ||
| 99 | committed := composeFrame(initial, composeCacheInput{}) | 99 | committed := composeFrame(initial, composeCacheInput{}) | ||
| 100 | require.Equal(t, '│', committed.frame.At(20, 1).Rune) | 100 | require.Equal(t, '│', committed.frame.At(20, 1).Rune) | ||
| @@ -170,7 +170,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |||||
| 170 | } | 170 | } | ||
| 171 | 171 | ||||
| 172 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | 172 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | ||
| 173 | - | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | 173 | + | theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} |
| 174 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | 174 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | ||
| 175 | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | 175 | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | ||
| 176 | 176 | ||||
| @@ -197,7 +197,6 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||||
| 197 | state := cachedStackTitleState("collapsed", 1, true) | 197 | state := cachedStackTitleState("collapsed", 1, true) | ||
| 198 | state.theme = themeui.Theme{ | 198 | state.theme = themeui.Theme{ | ||
| 199 | Known: true, | 199 | Known: true, | ||
| 200 | - | TrueColor: true, | |||
| 201 | HasFG: true, | 200 | HasFG: true, | ||
| 202 | HasBG: true, | 201 | HasBG: true, | ||
| 203 | Foreground: renderer.RGB{R: 220, G: 210, B: 200}, | 202 | Foreground: renderer.RGB{R: 220, G: 210, B: 200}, | ||
M internal/usecase/daemon/resume.go +10 -0
| @@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||
| 564 | 564 | ac.sendMu.Unlock() | |
| 565 | 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | |
| 566 | 566 | } | |
| 567 | + | colorBefore := ac.terminalCapabilities.Color.Mode | |
| 567 | 568 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | |
| 569 | + | colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore | |
| 568 | 570 | d.mu.Unlock() | |
| 569 | 571 | ac.sendMu.Unlock() | |
| 570 | 572 | if err == nil && ok && sess != nil && resumed != nil { | |
| @@ -572,6 +574,14 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||
| 572 | 574 | // complete the claiming terminal's geometry before the resumed | |
| 573 | 575 | // attachment is allowed to produce its first frame. | |
| 574 | 576 | sess.geometry.reconcile(d, sess, resumed) | |
| 577 | + | if colorChanged { | |
| 578 | + | // The applied theme (chrome styles) was resolved for the previous | |
| 579 | + | // terminal's color mode; re-resolve it for the new one before the | |
| 580 | + | // first paint, and tell the user about a declared downgrade just as a | |
| 581 | + | // fresh attach would. | |
| 582 | + | d.publishColorDowngradeNotice(sess, resumed) | |
| 583 | + | d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false) | |
| 584 | + | } | |
| 575 | 585 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | |
| 576 | 586 | // the restored scene at this boundary so suppression is explained once. | |
| 577 | 587 | d.warnUnsupportedGraphics(resumed) | |
| @@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||||
| 564 | ac.sendMu.Unlock() | 564 | ac.sendMu.Unlock() | ||
| 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 566 | } | 566 | } | ||
| 567 | + | colorBefore := ac.terminalCapabilities.Color.Mode | |||
| 567 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | 568 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | ||
| 569 | + | colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore | |||
| 568 | d.mu.Unlock() | 570 | d.mu.Unlock() | ||
| 569 | ac.sendMu.Unlock() | 571 | ac.sendMu.Unlock() | ||
| 570 | if err == nil && ok && sess != nil && resumed != nil { | 572 | if err == nil && ok && sess != nil && resumed != nil { | ||
| @@ -572,6 +574,14 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||||
| 572 | // complete the claiming terminal's geometry before the resumed | 574 | // complete the claiming terminal's geometry before the resumed | ||
| 573 | // attachment is allowed to produce its first frame. | 575 | // attachment is allowed to produce its first frame. | ||
| 574 | sess.geometry.reconcile(d, sess, resumed) | 576 | sess.geometry.reconcile(d, sess, resumed) | ||
| 577 | + | if colorChanged { | |||
| 578 | + | // The applied theme (chrome styles) was resolved for the previous | |||
| 579 | + | // terminal's color mode; re-resolve it for the new one before the | |||
| 580 | + | // first paint, and tell the user about a declared downgrade just as a | |||
| 581 | + | // fresh attach would. | |||
| 582 | + | d.publishColorDowngradeNotice(sess, resumed) | |||
| 583 | + | d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false) | |||
| 584 | + | } | |||
| 575 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | 585 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | ||
| 576 | // the restored scene at this boundary so suppression is explained once. | 586 | // the restored scene at this boundary so suppression is explained once. | ||
| 577 | d.warnUnsupportedGraphics(resumed) | 587 | d.warnUnsupportedGraphics(resumed) | ||
M internal/usecase/daemon/resume_test.go +2 -1
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | vt "github.com/bnema/vev-vt" | |
| 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 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol" | |
| @@ -1594,7 +1595,7 @@ func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) { | |||
| 1594 | 1595 | ||
| 1595 | 1596 | resumeHello := helloResumeCapable(protocol.IntentResume, "work", token) | |
| 1596 | 1597 | resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"} | |
| 1597 | - | resumeHello.TrueColor = true | |
| 1598 | + | resumeHello.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} | |
| 1598 | 1599 | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 1599 | 1600 | require.NoError(t, err) | |
| 1600 | 1601 | require.True(t, ok) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | vt "github.com/bnema/vev-vt" | 15 | vt "github.com/bnema/vev-vt" | ||
| 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 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | "github.com/bnema/vev/internal/protocol" | 20 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -1594,7 +1595,7 @@ func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) { | |||||
| 1594 | 1595 | ||||
| 1595 | resumeHello := helloResumeCapable(protocol.IntentResume, "work", token) | 1596 | resumeHello := helloResumeCapable(protocol.IntentResume, "work", token) | ||
| 1596 | resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"} | 1597 | resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"} | ||
| 1597 | - | resumeHello.TrueColor = true | 1598 | + | resumeHello.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} |
| 1598 | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | 1599 | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | ||
| 1599 | require.NoError(t, err) | 1600 | require.NoError(t, err) | ||
| 1600 | require.True(t, ok) | 1601 | require.True(t, ok) | ||
M internal/usecase/daemon/session_test.go +5 -4
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | 18 | "github.com/bnema/vev/internal/domain" | |
| 19 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 19 | 20 | "github.com/bnema/vev/internal/persist" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 21 | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| @@ -1618,7 +1619,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1618 | 1619 | tr := newMockServerConnection(t) | |
| 1619 | 1620 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1620 | 1621 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1621 | - | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | |
| 1622 | + | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr) | |
| 1622 | 1623 | require.NoError(t, err) | |
| 1623 | 1624 | defer func() { | |
| 1624 | 1625 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | |
| @@ -1663,9 +1664,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1663 | 1664 | tr2 := newMockServerConnection(t) | |
| 1664 | 1665 | tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1665 | 1666 | tr2.EXPECT().Close().Return(nil).Maybe() | |
| 1666 | - | sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1) | |
| 1667 | + | sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, tr1) | |
| 1667 | 1668 | require.NoError(t, err) | |
| 1668 | - | _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2) | |
| 1669 | + | _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr2) | |
| 1669 | 1670 | require.NoError(t, err) | |
| 1670 | 1671 | defer func() { | |
| 1671 | 1672 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | |
| @@ -1704,7 +1705,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1704 | 1705 | tr := newMockServerConnection(t) | |
| 1705 | 1706 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1706 | 1707 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1707 | - | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | |
| 1708 | + | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr) | |
| 1708 | 1709 | require.NoError(t, err) | |
| 1709 | 1710 | // The source coordinator is deliberately made pending so the switch must | |
| 1710 | 1711 | // invalidate it rather than letting its stale callback render for ac. | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 17 | 17 | ||||
| 18 | "github.com/bnema/vev/internal/domain" | 18 | "github.com/bnema/vev/internal/domain" | ||
| 19 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 19 | "github.com/bnema/vev/internal/persist" | 20 | "github.com/bnema/vev/internal/persist" | ||
| 20 | "github.com/bnema/vev/internal/ports" | 21 | "github.com/bnema/vev/internal/ports" | ||
| 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| @@ -1618,7 +1619,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1618 | tr := newMockServerConnection(t) | 1619 | tr := newMockServerConnection(t) | ||
| 1619 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1620 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| 1620 | tr.EXPECT().Close().Return(nil).Maybe() | 1621 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 1621 | - | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | 1622 | + | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr) |
| 1622 | require.NoError(t, err) | 1623 | require.NoError(t, err) | ||
| 1623 | defer func() { | 1624 | defer func() { | ||
| 1624 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | 1625 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | ||
| @@ -1663,9 +1664,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1663 | tr2 := newMockServerConnection(t) | 1664 | tr2 := newMockServerConnection(t) | ||
| 1664 | tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1665 | tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| 1665 | tr2.EXPECT().Close().Return(nil).Maybe() | 1666 | tr2.EXPECT().Close().Return(nil).Maybe() | ||
| 1666 | - | sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1) | 1667 | + | sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, tr1) |
| 1667 | require.NoError(t, err) | 1668 | require.NoError(t, err) | ||
| 1668 | - | _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2) | 1669 | + | _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr2) |
| 1669 | require.NoError(t, err) | 1670 | require.NoError(t, err) | ||
| 1670 | defer func() { | 1671 | defer func() { | ||
| 1671 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | 1672 | _ = d.killSession(sess, protocol.ReasonServerShutdown, false) | ||
| @@ -1704,7 +1705,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1704 | tr := newMockServerConnection(t) | 1705 | tr := newMockServerConnection(t) | ||
| 1705 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1706 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| 1706 | tr.EXPECT().Close().Return(nil).Maybe() | 1707 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 1707 | - | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | 1708 | + | sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr) |
| 1708 | require.NoError(t, err) | 1709 | require.NoError(t, err) | ||
| 1709 | // The source coordinator is deliberately made pending so the switch must | 1710 | // The source coordinator is deliberately made pending so the switch must | ||
| 1710 | // invalidate it rather than letting its stale callback render for ac. | 1711 | // invalidate it rather than letting its stale callback render for ac. | ||
M internal/usecase/daemon/status.go +16 -12
| @@ -27,15 +27,19 @@ func pulseVisible(frame int) bool { | |||
| 27 | 27 | return frame%pulseFrameCount != 0 | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | // pulseStyle returns the style for the attention bell glyph at frame, built | |
| 30 | + | // pulseStyleFor returns the style for the attention bell glyph at frame, built | |
| 31 | 31 | // on top of base so the bell always keeps the caller's background (the tab's | |
| 32 | 32 | // accent/statusBar, or a faded MRU entry's blended colors) instead of | |
| 33 | 33 | // punching a default-background hole in a themed bar. On the invisible beat | |
| 34 | - | // it returns (base, false) unchanged. | |
| 35 | - | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |
| 34 | + | // it returns (base, false) unchanged. reduced selects the attribute-only pulse | |
| 35 | + | // for chrome built by the reduced-color policy. | |
| 36 | + | func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) { | |
| 36 | 37 | if !pulseVisible(frame) { | |
| 37 | 38 | return base, false | |
| 38 | 39 | } | |
| 40 | + | if reduced { | |
| 41 | + | return themeui.PulseAttribute(base), true | |
| 42 | + | } | |
| 39 | 43 | f := frame % pulseFrameCount | |
| 40 | 44 | peak := pulseFrameCount / 2 | |
| 41 | 45 | distance := f - peak | |
| @@ -63,7 +67,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 63 | 67 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| 64 | 68 | if w.attention { | |
| 65 | 69 | writeStatusText(row, &x, " ", baseStyle) | |
| 66 | - | writeBell(row, &x, frame, baseStyle) | |
| 70 | + | writeBell(row, &x, frame, baseStyle, styles.Reduced) | |
| 67 | 71 | } | |
| 68 | 72 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 69 | 73 | writeStatusText(row, &x, " ", baseStyle) | |
| @@ -82,13 +86,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||
| 82 | 86 | if sess.selected { | |
| 83 | 87 | style = styles.SurfaceActive | |
| 84 | 88 | } | |
| 85 | - | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 89 | + | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | |
| 86 | 90 | } | |
| 87 | 91 | } else { | |
| 88 | 92 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 89 | 93 | for i, sess := range fittedMRU { | |
| 90 | 94 | style := styles.MRUStyle(i, len(fittedMRU)) | |
| 91 | - | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 95 | + | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | |
| 92 | 96 | } | |
| 93 | 97 | } | |
| 94 | 98 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | |
| @@ -103,23 +107,23 @@ type rankedRecent struct { | |||
| 103 | 107 | selected bool | |
| 104 | 108 | } | |
| 105 | 109 | ||
| 106 | - | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) { | |
| 110 | + | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) { | |
| 107 | 111 | prefixStyle := style | |
| 108 | 112 | prefixStyle.Bold = true | |
| 109 | 113 | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | |
| 110 | 114 | writeStatusText(row, x, sess.name, style) | |
| 111 | 115 | if sess.attention { | |
| 112 | 116 | writeStatusText(row, x, " ", style) | |
| 113 | - | writeBell(row, x, attentionFrame, style) | |
| 117 | + | writeBell(row, x, attentionFrame, style, reduced) | |
| 114 | 118 | } | |
| 115 | 119 | writeStatusText(row, x, " ", style) | |
| 116 | 120 | } | |
| 117 | 121 | ||
| 118 | - | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) { | |
| 122 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) { | |
| 119 | 123 | writeStatusText(row, x, " "+entry.name, style) | |
| 120 | 124 | if entry.attention { | |
| 121 | 125 | writeStatusText(row, x, " ", style) | |
| 122 | - | writeBell(row, x, attentionFrame, style) | |
| 126 | + | writeBell(row, x, attentionFrame, style, reduced) | |
| 123 | 127 | } | |
| 124 | 128 | writeStatusText(row, x, " ", style) | |
| 125 | 129 | } | |
| @@ -490,8 +494,8 @@ func mruFutureRightReserve(rowLen int) int { | |||
| 490 | 494 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 491 | 495 | // base's style on its blank beat, so the bell never leaves a bare | |
| 492 | 496 | // default-background hole in a themed bar. | |
| 493 | - | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | |
| 494 | - | style, visible := pulseStyle(frame, base) | |
| 497 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) { | |
| 498 | + | style, visible := pulseStyleFor(frame, base, reduced) | |
| 495 | 499 | if !visible { | |
| 496 | 500 | writeStatusText(row, x, " ", base) | |
| 497 | 501 | return | |
| @@ -27,15 +27,19 @@ func pulseVisible(frame int) bool { | |||||
| 27 | return frame%pulseFrameCount != 0 | 27 | return frame%pulseFrameCount != 0 | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | // pulseStyle returns the style for the attention bell glyph at frame, built | 30 | + | // pulseStyleFor returns the style for the attention bell glyph at frame, built |
| 31 | // on top of base so the bell always keeps the caller's background (the tab's | 31 | // on top of base so the bell always keeps the caller's background (the tab's | ||
| 32 | // accent/statusBar, or a faded MRU entry's blended colors) instead of | 32 | // accent/statusBar, or a faded MRU entry's blended colors) instead of | ||
| 33 | // punching a default-background hole in a themed bar. On the invisible beat | 33 | // punching a default-background hole in a themed bar. On the invisible beat | ||
| 34 | - | // it returns (base, false) unchanged. | 34 | + | // it returns (base, false) unchanged. reduced selects the attribute-only pulse |
| 35 | - | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | 35 | + | // for chrome built by the reduced-color policy. |
| 36 | + | func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) { | |||
| 36 | if !pulseVisible(frame) { | 37 | if !pulseVisible(frame) { | ||
| 37 | return base, false | 38 | return base, false | ||
| 38 | } | 39 | } | ||
| 40 | + | if reduced { | |||
| 41 | + | return themeui.PulseAttribute(base), true | |||
| 42 | + | } | |||
| 39 | f := frame % pulseFrameCount | 43 | f := frame % pulseFrameCount | ||
| 40 | peak := pulseFrameCount / 2 | 44 | peak := pulseFrameCount / 2 | ||
| 41 | distance := f - peak | 45 | distance := f - peak | ||
| @@ -63,7 +67,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 63 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | 67 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | ||
| 64 | if w.attention { | 68 | if w.attention { | ||
| 65 | writeStatusText(row, &x, " ", baseStyle) | 69 | writeStatusText(row, &x, " ", baseStyle) | ||
| 66 | - | writeBell(row, &x, frame, baseStyle) | 70 | + | writeBell(row, &x, frame, baseStyle, styles.Reduced) |
| 67 | } | 71 | } | ||
| 68 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | 72 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | ||
| 69 | writeStatusText(row, &x, " ", baseStyle) | 73 | writeStatusText(row, &x, " ", baseStyle) | ||
| @@ -82,13 +86,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||||
| 82 | if sess.selected { | 86 | if sess.selected { | ||
| 83 | style = styles.SurfaceActive | 87 | style = styles.SurfaceActive | ||
| 84 | } | 88 | } | ||
| 85 | - | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | 89 | + | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) |
| 86 | } | 90 | } | ||
| 87 | } else { | 91 | } else { | ||
| 88 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 92 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | ||
| 89 | for i, sess := range fittedMRU { | 93 | for i, sess := range fittedMRU { | ||
| 90 | style := styles.MRUStyle(i, len(fittedMRU)) | 94 | style := styles.MRUStyle(i, len(fittedMRU)) | ||
| 91 | - | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | 95 | + | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) |
| 92 | } | 96 | } | ||
| 93 | } | 97 | } | ||
| 94 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | 98 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | ||
| @@ -103,23 +107,23 @@ type rankedRecent struct { | |||||
| 103 | selected bool | 107 | selected bool | ||
| 104 | } | 108 | } | ||
| 105 | 109 | ||||
| 106 | - | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) { | 110 | + | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) { |
| 107 | prefixStyle := style | 111 | prefixStyle := style | ||
| 108 | prefixStyle.Bold = true | 112 | prefixStyle.Bold = true | ||
| 109 | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | 113 | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | ||
| 110 | writeStatusText(row, x, sess.name, style) | 114 | writeStatusText(row, x, sess.name, style) | ||
| 111 | if sess.attention { | 115 | if sess.attention { | ||
| 112 | writeStatusText(row, x, " ", style) | 116 | writeStatusText(row, x, " ", style) | ||
| 113 | - | writeBell(row, x, attentionFrame, style) | 117 | + | writeBell(row, x, attentionFrame, style, reduced) |
| 114 | } | 118 | } | ||
| 115 | writeStatusText(row, x, " ", style) | 119 | writeStatusText(row, x, " ", style) | ||
| 116 | } | 120 | } | ||
| 117 | 121 | ||||
| 118 | - | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) { | 122 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) { |
| 119 | writeStatusText(row, x, " "+entry.name, style) | 123 | writeStatusText(row, x, " "+entry.name, style) | ||
| 120 | if entry.attention { | 124 | if entry.attention { | ||
| 121 | writeStatusText(row, x, " ", style) | 125 | writeStatusText(row, x, " ", style) | ||
| 122 | - | writeBell(row, x, attentionFrame, style) | 126 | + | writeBell(row, x, attentionFrame, style, reduced) |
| 123 | } | 127 | } | ||
| 124 | writeStatusText(row, x, " ", style) | 128 | writeStatusText(row, x, " ", style) | ||
| 125 | } | 129 | } | ||
| @@ -490,8 +494,8 @@ func mruFutureRightReserve(rowLen int) int { | |||||
| 490 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | 494 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | ||
| 491 | // base's style on its blank beat, so the bell never leaves a bare | 495 | // base's style on its blank beat, so the bell never leaves a bare | ||
| 492 | // default-background hole in a themed bar. | 496 | // default-background hole in a themed bar. | ||
| 493 | - | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | 497 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) { |
| 494 | - | style, visible := pulseStyle(frame, base) | 498 | + | style, visible := pulseStyleFor(frame, base, reduced) |
| 495 | if !visible { | 499 | if !visible { | ||
| 496 | writeStatusText(row, x, " ", base) | 500 | writeStatusText(row, x, " ", base) | ||
| 497 | return | 501 | return | ||
M internal/usecase/daemon/status_test.go +12 -16
| @@ -297,7 +297,6 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 297 | 297 | HasFG: true, | |
| 298 | 298 | HasBG: true, | |
| 299 | 299 | Known: true, | |
| 300 | - | TrueColor: true, | |
| 301 | 300 | } | |
| 302 | 301 | styles := themeui.NewStyles(theme) | |
| 303 | 302 | require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| @@ -445,7 +444,6 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 445 | 444 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 446 | 445 | HasFG: true, | |
| 447 | 446 | HasBG: true, | |
| 448 | - | TrueColor: true, | |
| 449 | 447 | Known: true, | |
| 450 | 448 | }) | |
| 451 | 449 | ||
| @@ -523,7 +521,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | |||
| 523 | 521 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 524 | 522 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 525 | 523 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 526 | - | TrueColor: true, SchemeKnown: true, Light: !tc.want.Light, | |
| 524 | + | SchemeKnown: true, Light: !tc.want.Light, | |
| 527 | 525 | }) | |
| 528 | 526 | ||
| 529 | 527 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| @@ -578,7 +576,7 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |||
| 578 | 576 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 579 | 577 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 580 | 578 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 581 | - | TrueColor: true, SchemeKnown: true, Light: true, | |
| 579 | + | SchemeKnown: true, Light: true, | |
| 582 | 580 | }) | |
| 583 | 581 | assertSessionColorScheme(t, sess, true) | |
| 584 | 582 | ||
| @@ -595,7 +593,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) { | |||
| 595 | 593 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 596 | 594 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 597 | 595 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 598 | - | TrueColor: true, SchemeKnown: true, Light: true, | |
| 596 | + | SchemeKnown: true, Light: true, | |
| 599 | 597 | }) | |
| 600 | 598 | assertSessionColorScheme(t, sess, true) | |
| 601 | 599 | ||
| @@ -614,7 +612,7 @@ func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | |||
| 614 | 612 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 615 | 613 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 616 | 614 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 617 | - | TrueColor: true, SchemeKnown: true, Light: true, | |
| 615 | + | SchemeKnown: true, Light: true, | |
| 618 | 616 | }) | |
| 619 | 617 | ||
| 620 | 618 | d.clientGone(sess, ac, ac.transport(), true) | |
| @@ -631,12 +629,11 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) { | |||
| 631 | 629 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 632 | 630 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 633 | 631 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 634 | - | TrueColor: true, SchemeKnown: true, Light: true, | |
| 632 | + | SchemeKnown: true, Light: true, | |
| 635 | 633 | }) | |
| 636 | 634 | d.applyTheme(sess, ac, protocol.Theme{ | |
| 637 | 635 | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 638 | 636 | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | |
| 639 | - | TrueColor: true, | |
| 640 | 637 | }) | |
| 641 | 638 | ||
| 642 | 639 | assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12}) | |
| @@ -888,32 +885,32 @@ func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { | |||
| 888 | 885 | ||
| 889 | 886 | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | |
| 890 | 887 | for _, base := range []renderer.Style{rgbBase, indexedBase} { | |
| 891 | - | style, visible := pulseStyle(0, base) | |
| 888 | + | style, visible := pulseStyleFor(0, base, false) | |
| 892 | 889 | require.False(t, visible) | |
| 893 | 890 | require.True(t, style.Equal(base)) | |
| 894 | 891 | } | |
| 895 | 892 | }) | |
| 896 | 893 | ||
| 897 | 894 | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | |
| 898 | - | low, visible := pulseStyle(1, rgbBase) | |
| 895 | + | low, visible := pulseStyleFor(1, rgbBase, false) | |
| 899 | 896 | require.True(t, visible) | |
| 900 | 897 | require.True(t, low.Bold) | |
| 901 | 898 | require.True(t, low.HasBackgroundRGB) | |
| 902 | 899 | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | |
| 903 | 900 | ||
| 904 | - | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | |
| 901 | + | peak, visible := pulseStyleFor(pulseFrameCount/2, rgbBase, false) | |
| 905 | 902 | require.True(t, visible) | |
| 906 | 903 | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | |
| 907 | 904 | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | |
| 908 | 905 | }) | |
| 909 | 906 | ||
| 910 | 907 | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | |
| 911 | - | low, visible := pulseStyle(1, indexedBase) | |
| 908 | + | low, visible := pulseStyleFor(1, indexedBase, false) | |
| 912 | 909 | require.True(t, visible) | |
| 913 | 910 | require.True(t, low.Bold) | |
| 914 | 911 | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | |
| 915 | 912 | ||
| 916 | - | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) | |
| 913 | + | peak, visible := pulseStyleFor(pulseFrameCount/2, indexedBase, false) | |
| 917 | 914 | require.True(t, visible) | |
| 918 | 915 | require.Greater(t, peak.Foreground, low.Foreground) | |
| 919 | 916 | }) | |
| @@ -1094,7 +1091,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||
| 1094 | 1091 | } | |
| 1095 | 1092 | ||
| 1096 | 1093 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |
| 1097 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 1094 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true} | |
| 1098 | 1095 | styles := themeui.NewStyles(theme) | |
| 1099 | 1096 | row := make([]renderer.Cell, 16) | |
| 1100 | 1097 | ||
| @@ -1141,7 +1138,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||
| 1141 | 1138 | } | |
| 1142 | 1139 | ||
| 1143 | 1140 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 1144 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 1141 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true} | |
| 1145 | 1142 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 1146 | 1143 | theme.Palette[10] = theme.Palette[2] | |
| 1147 | 1144 | theme.PaletteKnown = 1<<2 | 1<<10 | |
| @@ -1344,7 +1341,6 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||
| 1344 | 1341 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 1345 | 1342 | HasFG: true, | |
| 1346 | 1343 | HasBG: true, | |
| 1347 | - | TrueColor: true, | |
| 1348 | 1344 | Known: true, | |
| 1349 | 1345 | UsePalette: true, | |
| 1350 | 1346 | } | |
| @@ -297,7 +297,6 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 297 | HasFG: true, | 297 | HasFG: true, | ||
| 298 | HasBG: true, | 298 | HasBG: true, | ||
| 299 | Known: true, | 299 | Known: true, | ||
| 300 | - | TrueColor: true, | |||
| 301 | } | 300 | } | ||
| 302 | styles := themeui.NewStyles(theme) | 301 | styles := themeui.NewStyles(theme) | ||
| 303 | require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | 302 | require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | ||
| @@ -445,7 +444,6 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||||
| 445 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | 444 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | ||
| 446 | HasFG: true, | 445 | HasFG: true, | ||
| 447 | HasBG: true, | 446 | HasBG: true, | ||
| 448 | - | TrueColor: true, | |||
| 449 | Known: true, | 447 | Known: true, | ||
| 450 | }) | 448 | }) | ||
| 451 | 449 | ||||
| @@ -523,7 +521,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | |||||
| 523 | d.applyTheme(sess, ac, protocol.Theme{ | 521 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 524 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 522 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 525 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 523 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 526 | - | TrueColor: true, SchemeKnown: true, Light: !tc.want.Light, | 524 | + | SchemeKnown: true, Light: !tc.want.Light, |
| 527 | }) | 525 | }) | ||
| 528 | 526 | ||||
| 529 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | 527 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | ||
| @@ -578,7 +576,7 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |||||
| 578 | d.applyTheme(sess, ac, protocol.Theme{ | 576 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 579 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 577 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 580 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 578 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 581 | - | TrueColor: true, SchemeKnown: true, Light: true, | 579 | + | SchemeKnown: true, Light: true, |
| 582 | }) | 580 | }) | ||
| 583 | assertSessionColorScheme(t, sess, true) | 581 | assertSessionColorScheme(t, sess, true) | ||
| 584 | 582 | ||||
| @@ -595,7 +593,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) { | |||||
| 595 | d.applyTheme(sess, ac, protocol.Theme{ | 593 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 596 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 594 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 597 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 595 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 598 | - | TrueColor: true, SchemeKnown: true, Light: true, | 596 | + | SchemeKnown: true, Light: true, |
| 599 | }) | 597 | }) | ||
| 600 | assertSessionColorScheme(t, sess, true) | 598 | assertSessionColorScheme(t, sess, true) | ||
| 601 | 599 | ||||
| @@ -614,7 +612,7 @@ func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | |||||
| 614 | d.applyTheme(sess, ac, protocol.Theme{ | 612 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 615 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 613 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 616 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 614 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 617 | - | TrueColor: true, SchemeKnown: true, Light: true, | 615 | + | SchemeKnown: true, Light: true, |
| 618 | }) | 616 | }) | ||
| 619 | 617 | ||||
| 620 | d.clientGone(sess, ac, ac.transport(), true) | 618 | d.clientGone(sess, ac, ac.transport(), true) | ||
| @@ -631,12 +629,11 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) { | |||||
| 631 | d.applyTheme(sess, ac, protocol.Theme{ | 629 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 632 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 630 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 633 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 631 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 634 | - | TrueColor: true, SchemeKnown: true, Light: true, | 632 | + | SchemeKnown: true, Light: true, |
| 635 | }) | 633 | }) | ||
| 636 | d.applyTheme(sess, ac, protocol.Theme{ | 634 | d.applyTheme(sess, ac, protocol.Theme{ | ||
| 637 | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | 635 | HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | ||
| 638 | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | 636 | HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, | ||
| 639 | - | TrueColor: true, | |||
| 640 | }) | 637 | }) | ||
| 641 | 638 | ||||
| 642 | assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12}) | 639 | assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12}) | ||
| @@ -888,32 +885,32 @@ func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { | |||||
| 888 | 885 | ||||
| 889 | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | 886 | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | ||
| 890 | for _, base := range []renderer.Style{rgbBase, indexedBase} { | 887 | for _, base := range []renderer.Style{rgbBase, indexedBase} { | ||
| 891 | - | style, visible := pulseStyle(0, base) | 888 | + | style, visible := pulseStyleFor(0, base, false) |
| 892 | require.False(t, visible) | 889 | require.False(t, visible) | ||
| 893 | require.True(t, style.Equal(base)) | 890 | require.True(t, style.Equal(base)) | ||
| 894 | } | 891 | } | ||
| 895 | }) | 892 | }) | ||
| 896 | 893 | ||||
| 897 | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | 894 | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | ||
| 898 | - | low, visible := pulseStyle(1, rgbBase) | 895 | + | low, visible := pulseStyleFor(1, rgbBase, false) |
| 899 | require.True(t, visible) | 896 | require.True(t, visible) | ||
| 900 | require.True(t, low.Bold) | 897 | require.True(t, low.Bold) | ||
| 901 | require.True(t, low.HasBackgroundRGB) | 898 | require.True(t, low.HasBackgroundRGB) | ||
| 902 | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | 899 | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | ||
| 903 | 900 | ||||
| 904 | - | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | 901 | + | peak, visible := pulseStyleFor(pulseFrameCount/2, rgbBase, false) |
| 905 | require.True(t, visible) | 902 | require.True(t, visible) | ||
| 906 | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | 903 | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | ||
| 907 | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | 904 | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | ||
| 908 | }) | 905 | }) | ||
| 909 | 906 | ||||
| 910 | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | 907 | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | ||
| 911 | - | low, visible := pulseStyle(1, indexedBase) | 908 | + | low, visible := pulseStyleFor(1, indexedBase, false) |
| 912 | require.True(t, visible) | 909 | require.True(t, visible) | ||
| 913 | require.True(t, low.Bold) | 910 | require.True(t, low.Bold) | ||
| 914 | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | 911 | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | ||
| 915 | 912 | ||||
| 916 | - | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) | 913 | + | peak, visible := pulseStyleFor(pulseFrameCount/2, indexedBase, false) |
| 917 | require.True(t, visible) | 914 | require.True(t, visible) | ||
| 918 | require.Greater(t, peak.Foreground, low.Foreground) | 915 | require.Greater(t, peak.Foreground, low.Foreground) | ||
| 919 | }) | 916 | }) | ||
| @@ -1094,7 +1091,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||||
| 1094 | } | 1091 | } | ||
| 1095 | 1092 | ||||
| 1096 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | 1093 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | ||
| 1097 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | 1094 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true} |
| 1098 | styles := themeui.NewStyles(theme) | 1095 | styles := themeui.NewStyles(theme) | ||
| 1099 | row := make([]renderer.Cell, 16) | 1096 | row := make([]renderer.Cell, 16) | ||
| 1100 | 1097 | ||||
| @@ -1141,7 +1138,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||||
| 1141 | } | 1138 | } | ||
| 1142 | 1139 | ||||
| 1143 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | 1140 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | ||
| 1144 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | 1141 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true} |
| 1145 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | 1142 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | ||
| 1146 | theme.Palette[10] = theme.Palette[2] | 1143 | theme.Palette[10] = theme.Palette[2] | ||
| 1147 | theme.PaletteKnown = 1<<2 | 1<<10 | 1144 | theme.PaletteKnown = 1<<2 | 1<<10 | ||
| @@ -1344,7 +1341,6 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||||
| 1344 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | 1341 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | ||
| 1345 | HasFG: true, | 1342 | HasFG: true, | ||
| 1346 | HasBG: true, | 1343 | HasBG: true, | ||
| 1347 | - | TrueColor: true, | |||
| 1348 | Known: true, | 1344 | Known: true, | ||
| 1349 | UsePalette: true, | 1345 | UsePalette: true, | ||
| 1350 | } | 1346 | } | ||
M internal/usecase/daemon/theme_cache_test.go +2 -2
| @@ -18,7 +18,7 @@ func cacheTheme() themeui.Theme { | |||
| 18 | 18 | palette[10] = palette[2] | |
| 19 | 19 | return themeui.Theme{ | |
| 20 | 20 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 21 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 21 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, | |
| 22 | 22 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 23 | 23 | } | |
| 24 | 24 | } | |
| @@ -26,7 +26,7 @@ func cacheTheme() themeui.Theme { | |||
| 26 | 26 | func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) { | |
| 27 | 27 | d, sess, ac, _ := newManualSessionWithPTYs(t) | |
| 28 | 28 | raw := cacheTheme() | |
| 29 | - | d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}) | |
| 29 | + | d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}) | |
| 30 | 30 | before := ac.getAppliedTheme() | |
| 31 | 31 | ||
| 32 | 32 | cfg := domain.Defaults() | |
| @@ -18,7 +18,7 @@ func cacheTheme() themeui.Theme { | |||||
| 18 | palette[10] = palette[2] | 18 | palette[10] = palette[2] | ||
| 19 | return themeui.Theme{ | 19 | return themeui.Theme{ | ||
| 20 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | 20 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | ||
| 21 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | 21 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, |
| 22 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | 22 | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | ||
| 23 | } | 23 | } | ||
| 24 | } | 24 | } | ||
| @@ -26,7 +26,7 @@ func cacheTheme() themeui.Theme { | |||||
| 26 | func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) { | 26 | func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) { | ||
| 27 | d, sess, ac, _ := newManualSessionWithPTYs(t) | 27 | d, sess, ac, _ := newManualSessionWithPTYs(t) | ||
| 28 | raw := cacheTheme() | 28 | raw := cacheTheme() | ||
| 29 | - | d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}) | 29 | + | d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}) |
| 30 | before := ac.getAppliedTheme() | 30 | before := ac.getAppliedTheme() | ||
| 31 | 31 | ||||
| 32 | cfg := domain.Defaults() | 32 | cfg := domain.Defaults() | ||
M internal/usecase/daemon/theme_integration_test.go +3 -4
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | 13 | ) | |
| @@ -14,8 +15,7 @@ import ( | |||
| 14 | 15 | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme { | |
| 15 | 16 | out := protocol.Theme{ | |
| 16 | 17 | Foreground: foreground, Background: background, | |
| 17 | - | HasForeground: true, HasBackground: true, TrueColor: true, | |
| 18 | - | SchemeKnown: true, | |
| 18 | + | HasForeground: true, HasBackground: true, SchemeKnown: true, | |
| 19 | 19 | } | |
| 20 | 20 | for slot, color := range palette { | |
| 21 | 21 | out.Palette[slot] = color | |
| @@ -175,8 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) { | |||
| 175 | 175 | d.ApplyConfig(tt.config) | |
| 176 | 176 | got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{ | |
| 177 | 177 | Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown, | |
| 178 | - | HasFG: true, HasBG: true, TrueColor: true, | |
| 179 | - | }).Resolved.Styles) | |
| 178 | + | HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles) | |
| 180 | 179 | require.Equal(t, []byte(tt.want), got) | |
| 181 | 180 | }) | |
| 182 | 181 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt/ansi" | 8 | renderer "github.com/bnema/vev-vt/ansi" | ||
| 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/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 12 | ) | 13 | ) | ||
| @@ -14,8 +15,7 @@ import ( | |||||
| 14 | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme { | 15 | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme { | ||
| 15 | out := protocol.Theme{ | 16 | out := protocol.Theme{ | ||
| 16 | Foreground: foreground, Background: background, | 17 | Foreground: foreground, Background: background, | ||
| 17 | - | HasForeground: true, HasBackground: true, TrueColor: true, | 18 | + | HasForeground: true, HasBackground: true, SchemeKnown: true, |
| 18 | - | SchemeKnown: true, | |||
| 19 | } | 19 | } | ||
| 20 | for slot, color := range palette { | 20 | for slot, color := range palette { | ||
| 21 | out.Palette[slot] = color | 21 | out.Palette[slot] = color | ||
| @@ -175,8 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) { | |||||
| 175 | d.ApplyConfig(tt.config) | 175 | d.ApplyConfig(tt.config) | ||
| 176 | got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{ | 176 | got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{ | ||
| 177 | Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown, | 177 | Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown, | ||
| 178 | - | HasFG: true, HasBG: true, TrueColor: true, | 178 | + | HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles) |
| 179 | - | }).Resolved.Styles) | |||
| 180 | require.Equal(t, []byte(tt.want), got) | 179 | require.Equal(t, []byte(tt.want), got) | ||
| 181 | }) | 180 | }) | ||
| 182 | } | 181 | } | ||
M internal/usecase/daemon/theme_resolve.go +9 -2
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/usecase/theme" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/theme" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 6 | 9 | if ac != nil { | |
| @@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 16 | 19 | // Resolve while applying, never while composing. The theme mutex only | |
| 17 | 20 | // publishes the completed value and is not held across session/tab/pane | |
| 18 | 21 | // locks, preserving the daemon lock order. | |
| 19 | - | applied := d.resolveAppliedTheme(t) | |
| 22 | + | var color terminalcap.ColorCapabilities | |
| 23 | + | if ac != nil { | |
| 24 | + | color = ac.terminalCapabilities.Color | |
| 25 | + | } | |
| 26 | + | applied := d.resolveAppliedTheme(t, color) | |
| 20 | 27 | t = applied.Raw | |
| 21 | 28 | sess.mu.Lock() | |
| 22 | 29 | if ac != nil { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/usecase/theme" | 3 | + | import ( |
| 4 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/theme" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | 8 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | ||
| 6 | if ac != nil { | 9 | if ac != nil { | ||
| @@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||||
| 16 | // Resolve while applying, never while composing. The theme mutex only | 19 | // Resolve while applying, never while composing. The theme mutex only | ||
| 17 | // publishes the completed value and is not held across session/tab/pane | 20 | // publishes the completed value and is not held across session/tab/pane | ||
| 18 | // locks, preserving the daemon lock order. | 21 | // locks, preserving the daemon lock order. | ||
| 19 | - | applied := d.resolveAppliedTheme(t) | 22 | + | var color terminalcap.ColorCapabilities |
| 23 | + | if ac != nil { | |||
| 24 | + | color = ac.terminalCapabilities.Color | |||
| 25 | + | } | |||
| 26 | + | applied := d.resolveAppliedTheme(t, color) | |||
| 20 | t = applied.Raw | 27 | t = applied.Raw | ||
| 21 | sess.mu.Lock() | 28 | sess.mu.Lock() | ||
| 22 | if ac != nil { | 29 | if ac != nil { | ||
M internal/usecase/palette/model_test.go +1 -2
| @@ -503,11 +503,10 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||
| 503 | 503 | paletteColors[10] = paletteColors[2] | |
| 504 | 504 | accentTheme := themeui.Theme{ | |
| 505 | 505 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 506 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 506 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, | |
| 507 | 507 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 508 | 508 | } | |
| 509 | 509 | indexedTheme := accentTheme | |
| 510 | - | indexedTheme.TrueColor = false | |
| 511 | 510 | paletteOffTheme := accentTheme | |
| 512 | 511 | paletteOffTheme.UsePalette = false | |
| 513 | 512 | neutralTheme := paletteOffTheme | |
| @@ -503,11 +503,10 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||||
| 503 | paletteColors[10] = paletteColors[2] | 503 | paletteColors[10] = paletteColors[2] | ||
| 504 | accentTheme := themeui.Theme{ | 504 | accentTheme := themeui.Theme{ | ||
| 505 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | 505 | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | ||
| 506 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | 506 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, |
| 507 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | 507 | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | ||
| 508 | } | 508 | } | ||
| 509 | indexedTheme := accentTheme | 509 | indexedTheme := accentTheme | ||
| 510 | - | indexedTheme.TrueColor = false | |||
| 511 | paletteOffTheme := accentTheme | 510 | paletteOffTheme := accentTheme | ||
| 512 | paletteOffTheme.UsePalette = false | 511 | paletteOffTheme.UsePalette = false | ||
| 513 | neutralTheme := paletteOffTheme | 512 | neutralTheme := paletteOffTheme | ||
M internal/usecase/picker/model.go +21 -3
| @@ -61,6 +61,21 @@ type RenderStyles struct { | |||
| 61 | 61 | Status renderer.Style // one-row picker-local contextual help | |
| 62 | 62 | SearchMatch renderer.Style // matched runes on ordinary rows | |
| 63 | 63 | SelectionMatch renderer.Style // matched runes on the selected row | |
| 64 | + | // ProblemBold draws error and warning dots bold. A monochrome terminal | |
| 65 | + | // drops the fixed problem colors, so weight is what sets them apart. | |
| 66 | + | ProblemBold bool | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // DefaultRenderStyles returns the picker's built-in styles. monochrome selects | |
| 70 | + | // the attribute-only variant: tree lines are dimmed instead of grey and | |
| 71 | + | // problem dots are bold, since no color survives on such a terminal. | |
| 72 | + | func DefaultRenderStyles(monochrome bool) RenderStyles { | |
| 73 | + | styles := defaultRenderStyles() | |
| 74 | + | if monochrome { | |
| 75 | + | styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style | |
| 76 | + | styles.ProblemBold = true | |
| 77 | + | } | |
| 78 | + | return styles | |
| 64 | 79 | } | |
| 65 | 80 | ||
| 66 | 81 | func defaultRenderStyles() RenderStyles { | |
| @@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||
| 585 | 600 | ||
| 586 | 601 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | |
| 587 | 602 | // its own color even on the inverse selected row. | |
| 588 | - | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | |
| 603 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) { | |
| 589 | 604 | if badge == "" { | |
| 590 | 605 | return | |
| 591 | 606 | } | |
| @@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge | |||
| 593 | 608 | style.Inverse = false | |
| 594 | 609 | style.Foreground = lineStatusColor(status) | |
| 595 | 610 | style.HasForegroundRGB = false | |
| 611 | + | if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) { | |
| 612 | + | style.Bold = true | |
| 613 | + | } | |
| 596 | 614 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | |
| 597 | 615 | } | |
| 598 | 616 | ||
| @@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 681 | 699 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | |
| 682 | 700 | ||
| 683 | 701 | if r.section() { | |
| 684 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 702 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) | |
| 685 | 703 | continue | |
| 686 | 704 | } | |
| 687 | 705 | if r.rendersAsHeader() { | |
| @@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 697 | 715 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 698 | 716 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 699 | 717 | } | |
| 700 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 718 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) | |
| 701 | 719 | continue | |
| 702 | 720 | } | |
| 703 | 721 | ||
| @@ -61,6 +61,21 @@ type RenderStyles struct { | |||||
| 61 | Status renderer.Style // one-row picker-local contextual help | 61 | Status renderer.Style // one-row picker-local contextual help | ||
| 62 | SearchMatch renderer.Style // matched runes on ordinary rows | 62 | SearchMatch renderer.Style // matched runes on ordinary rows | ||
| 63 | SelectionMatch renderer.Style // matched runes on the selected row | 63 | SelectionMatch renderer.Style // matched runes on the selected row | ||
| 64 | + | // ProblemBold draws error and warning dots bold. A monochrome terminal | |||
| 65 | + | // drops the fixed problem colors, so weight is what sets them apart. | |||
| 66 | + | ProblemBold bool | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // DefaultRenderStyles returns the picker's built-in styles. monochrome selects | |||
| 70 | + | // the attribute-only variant: tree lines are dimmed instead of grey and | |||
| 71 | + | // problem dots are bold, since no color survives on such a terminal. | |||
| 72 | + | func DefaultRenderStyles(monochrome bool) RenderStyles { | |||
| 73 | + | styles := defaultRenderStyles() | |||
| 74 | + | if monochrome { | |||
| 75 | + | styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style | |||
| 76 | + | styles.ProblemBold = true | |||
| 77 | + | } | |||
| 78 | + | return styles | |||
| 64 | } | 79 | } | ||
| 65 | 80 | ||||
| 66 | func defaultRenderStyles() RenderStyles { | 81 | func defaultRenderStyles() RenderStyles { | ||
| @@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||||
| 585 | 600 | ||||
| 586 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | 601 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | ||
| 587 | // its own color even on the inverse selected row. | 602 | // its own color even on the inverse selected row. | ||
| 588 | - | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | 603 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) { |
| 589 | if badge == "" { | 604 | if badge == "" { | ||
| 590 | return | 605 | return | ||
| 591 | } | 606 | } | ||
| @@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge | |||||
| 593 | style.Inverse = false | 608 | style.Inverse = false | ||
| 594 | style.Foreground = lineStatusColor(status) | 609 | style.Foreground = lineStatusColor(status) | ||
| 595 | style.HasForegroundRGB = false | 610 | style.HasForegroundRGB = false | ||
| 611 | + | if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) { | |||
| 612 | + | style.Bold = true | |||
| 613 | + | } | |||
| 596 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | 614 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | ||
| 597 | } | 615 | } | ||
| 598 | 616 | ||||
| @@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 681 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | 699 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | ||
| 682 | 700 | ||||
| 683 | if r.section() { | 701 | if r.section() { | ||
| 684 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | 702 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) |
| 685 | continue | 703 | continue | ||
| 686 | } | 704 | } | ||
| 687 | if r.rendersAsHeader() { | 705 | if r.rendersAsHeader() { | ||
| @@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 697 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | 715 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | ||
| 698 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | 716 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | ||
| 699 | } | 717 | } | ||
| 700 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | 718 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) |
| 701 | continue | 719 | continue | ||
| 702 | } | 720 | } | ||
| 703 | 721 | ||||
M internal/usecase/theme/accent_resolve_test.go +1 -3
| @@ -131,7 +131,6 @@ func TestResolveAccentAuto(t *testing.T) { | |||
| 131 | 131 | name: "known colors remain semantic without truecolor output", | |
| 132 | 132 | theme: func() Theme { | |
| 133 | 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 134 | - | t.TrueColor = false | |
| 135 | 134 | return t | |
| 136 | 135 | }(), | |
| 137 | 136 | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| @@ -206,7 +205,7 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||
| 206 | 205 | }, | |
| 207 | 206 | { | |
| 208 | 207 | name: "explicit known slot remains semantic without truecolor output", | |
| 209 | - | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | |
| 208 | + | theme: paletteTheme(map[int]renderer.RGB{3: teal}), | |
| 210 | 209 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 211 | 210 | want: Accent{RGB: teal, Slot: 3, Known: true}, | |
| 212 | 211 | }, | |
| @@ -273,7 +272,6 @@ func paletteTheme(colors map[int]renderer.RGB) Theme { | |||
| 273 | 272 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 274 | 273 | HasFG: true, | |
| 275 | 274 | HasBG: true, | |
| 276 | - | TrueColor: true, | |
| 277 | 275 | Known: true, | |
| 278 | 276 | UsePalette: true, | |
| 279 | 277 | } | |
| @@ -131,7 +131,6 @@ func TestResolveAccentAuto(t *testing.T) { | |||||
| 131 | name: "known colors remain semantic without truecolor output", | 131 | name: "known colors remain semantic without truecolor output", | ||
| 132 | theme: func() Theme { | 132 | theme: func() Theme { | ||
| 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | ||
| 134 | - | t.TrueColor = false | |||
| 135 | return t | 134 | return t | ||
| 136 | }(), | 135 | }(), | ||
| 137 | want: Accent{RGB: teal, Slot: 2, Known: true}, | 136 | want: Accent{RGB: teal, Slot: 2, Known: true}, | ||
| @@ -206,7 +205,7 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||||
| 206 | }, | 205 | }, | ||
| 207 | { | 206 | { | ||
| 208 | name: "explicit known slot remains semantic without truecolor output", | 207 | name: "explicit known slot remains semantic without truecolor output", | ||
| 209 | - | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | 208 | + | theme: paletteTheme(map[int]renderer.RGB{3: teal}), |
| 210 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | 209 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | ||
| 211 | want: Accent{RGB: teal, Slot: 3, Known: true}, | 210 | want: Accent{RGB: teal, Slot: 3, Known: true}, | ||
| 212 | }, | 211 | }, | ||
| @@ -273,7 +272,6 @@ func paletteTheme(colors map[int]renderer.RGB) Theme { | |||||
| 273 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | 272 | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | ||
| 274 | HasFG: true, | 273 | HasFG: true, | ||
| 275 | HasBG: true, | 274 | HasBG: true, | ||
| 276 | - | TrueColor: true, | |||
| 277 | Known: true, | 275 | Known: true, | ||
| 278 | UsePalette: true, | 276 | UsePalette: true, | ||
| 279 | } | 277 | } | ||
M internal/usecase/theme/benchmark_test.go +0 -1
| @@ -26,7 +26,6 @@ func benchmarkPaletteTheme() Theme { | |||
| 26 | 26 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | |
| 27 | 27 | HasFG: true, | |
| 28 | 28 | HasBG: true, | |
| 29 | - | TrueColor: true, | |
| 30 | 29 | Known: true, | |
| 31 | 30 | SchemeKnown: true, | |
| 32 | 31 | UsePalette: true, | |
| @@ -26,7 +26,6 @@ func benchmarkPaletteTheme() Theme { | |||||
| 26 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | 26 | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | ||
| 27 | HasFG: true, | 27 | HasFG: true, | ||
| 28 | HasBG: true, | 28 | HasBG: true, | ||
| 29 | - | TrueColor: true, | |||
| 30 | Known: true, | 29 | Known: true, | ||
| 31 | SchemeKnown: true, | 30 | SchemeKnown: true, | ||
| 32 | UsePalette: true, | 31 | UsePalette: true, | ||
A internal/usecase/theme/color_policy.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 5 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // reducedColor reports whether chrome for c must be built from attributes and | |
| 10 | + | // terminal palette slots instead of RGB surfaces: 16-color and monochrome | |
| 11 | + | // attachments. | |
| 12 | + | func reducedColor(c terminalcap.ColorCapabilities) bool { | |
| 13 | + | return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // ResolveForColor is Resolve for one attachment's color capability. TrueColor | |
| 17 | + | // and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome | |
| 18 | + | // replace the chrome styles with the reduced-color policy (see reducedStyles) | |
| 19 | + | // and mark the returned Theme so dimming uses the faint attribute instead of | |
| 20 | + | // RGB blends. Color is per attachment, so callers resolve once per attachment. | |
| 21 | + | func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme { | |
| 22 | + | if !reducedColor(color) { | |
| 23 | + | return Resolve(t, policy) | |
| 24 | + | } | |
| 25 | + | t.DimByAttribute = true | |
| 26 | + | resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute | |
| 27 | + | accent := resolved.Accent | |
| 28 | + | slot := -1 | |
| 29 | + | if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 { | |
| 30 | + | slot = int(accent.Slot) | |
| 31 | + | } | |
| 32 | + | resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome) | |
| 33 | + | return resolved | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // reducedStyles builds the chrome for terminals that cannot show tinted RGB | |
| 37 | + | // surfaces. Surfaces keep the terminal's default background; hierarchy comes | |
| 38 | + | // from attributes, and the accent (when known) is only ever a palette slot | |
| 39 | + | // 0-15 so the terminal's own palette renders it: | |
| 40 | + | // | |
| 41 | + | // - active tab, session name, selected rows, hint keys, search selection: | |
| 42 | + | // bold reverse, so contrast is always the terminal's own fg/bg pair; | |
| 43 | + | // - copy status, copy selection and the focused pane title: plain reverse | |
| 44 | + | // (a selection keeps the text's own colors); | |
| 45 | + | // - inactive tabs and bars: plain; secondary text, muted borders and | |
| 46 | + | // unfocused titles: faint; | |
| 47 | + | // - focused borders: bold, in the accent palette slot when there is one; | |
| 48 | + | // warn borders: bold yellow (slot 3); errors share the focused border. | |
| 49 | + | // | |
| 50 | + | // Monochrome uses the same attributes with no color at all. The accent never | |
| 51 | + | // fills a background: a palette slot is not guaranteed to contrast with the | |
| 52 | + | // terminal background that reverse video would put on top of it. | |
| 53 | + | func reducedStyles(accentSlot int, mono bool) Styles { | |
| 54 | + | plain := renderer.DefaultStyle() | |
| 55 | + | withFG := func(s renderer.Style, slot int) renderer.Style { | |
| 56 | + | if !mono && slot >= 0 { | |
| 57 | + | s.Foreground = slot | |
| 58 | + | } | |
| 59 | + | return s | |
| 60 | + | } | |
| 61 | + | with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style { | |
| 62 | + | base.Bold, base.Italic, base.Inverse = bold, italic, inverse | |
| 63 | + | if faint { | |
| 64 | + | base.Attrs |= renderer.AttrDim | |
| 65 | + | } | |
| 66 | + | return base | |
| 67 | + | } | |
| 68 | + | active := with(plain, true, false, true, false) | |
| 69 | + | activeTitle := with(plain, false, false, true, false) | |
| 70 | + | selectionMuted := with(plain, false, true, true, false) | |
| 71 | + | faint := with(plain, false, false, false, true) | |
| 72 | + | description := with(plain, false, true, false, true) | |
| 73 | + | focusedBorder := with(withFG(plain, accentSlot), true, false, false, false) | |
| 74 | + | warn := with(plain, true, true, false, false) | |
| 75 | + | if !mono { | |
| 76 | + | warn = with(withFG(plain, 3), true, false, false, false) | |
| 77 | + | } | |
| 78 | + | copySelection := with(plain, false, false, true, false) | |
| 79 | + | ||
| 80 | + | s := Styles{ | |
| 81 | + | Reduced: true, | |
| 82 | + | ||
| 83 | + | SurfaceBar: plain, | |
| 84 | + | SurfaceInactive: plain, | |
| 85 | + | SurfaceRecent: plain, | |
| 86 | + | SurfaceActive: active, | |
| 87 | + | BorderMuted: faint, | |
| 88 | + | BorderActive: focusedBorder, | |
| 89 | + | BorderWarn: warn, | |
| 90 | + | NeutralBorder: faint, | |
| 91 | + | ||
| 92 | + | TabInactive: plain, | |
| 93 | + | TabInactiveTitle: faint, | |
| 94 | + | TabActive: active, | |
| 95 | + | TabActiveTitle: activeTitle, | |
| 96 | + | MRURecent: plain, | |
| 97 | + | ||
| 98 | + | PickerBase: plain, | |
| 99 | + | PickerSelection: active, | |
| 100 | + | HintKey: active, | |
| 101 | + | PickerDescription: description, | |
| 102 | + | PickerSeparator: faint, | |
| 103 | + | PromptBase: plain, | |
| 104 | + | CopyStatus: with(plain, false, false, true, false), | |
| 105 | + | SearchSelection: active, | |
| 106 | + | } | |
| 107 | + | // Aliases consumed by render paths that have not migrated yet. Selection | |
| 108 | + | // is the copy-mode selection role and StatusBar the focused pane title | |
| 109 | + | // bar; both are plain reverse. | |
| 110 | + | s.StatusBar = copySelection | |
| 111 | + | s.Accent = s.SurfaceActive | |
| 112 | + | s.Border = s.BorderMuted | |
| 113 | + | s.Selection = copySelection | |
| 114 | + | s.PaletteDesc = description | |
| 115 | + | s.TabName = s.TabInactive | |
| 116 | + | s.TabNameActive = s.TabActive | |
| 117 | + | s.TabTitle = s.TabInactiveTitle | |
| 118 | + | s.TabTitleActive = s.TabActiveTitle | |
| 119 | + | s.PickerName = plain | |
| 120 | + | s.PickerSelectionName = active | |
| 121 | + | s.PickerSelectionMuted = selectionMuted | |
| 122 | + | for count := range s.mruStyles { | |
| 123 | + | for index := 0; index <= count; index++ { | |
| 124 | + | s.mruStyles[count][index] = plain | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return s | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 5 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // reducedColor reports whether chrome for c must be built from attributes and | |||
| 10 | + | // terminal palette slots instead of RGB surfaces: 16-color and monochrome | |||
| 11 | + | // attachments. | |||
| 12 | + | func reducedColor(c terminalcap.ColorCapabilities) bool { | |||
| 13 | + | return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // ResolveForColor is Resolve for one attachment's color capability. TrueColor | |||
| 17 | + | // and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome | |||
| 18 | + | // replace the chrome styles with the reduced-color policy (see reducedStyles) | |||
| 19 | + | // and mark the returned Theme so dimming uses the faint attribute instead of | |||
| 20 | + | // RGB blends. Color is per attachment, so callers resolve once per attachment. | |||
| 21 | + | func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme { | |||
| 22 | + | if !reducedColor(color) { | |||
| 23 | + | return Resolve(t, policy) | |||
| 24 | + | } | |||
| 25 | + | t.DimByAttribute = true | |||
| 26 | + | resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute | |||
| 27 | + | accent := resolved.Accent | |||
| 28 | + | slot := -1 | |||
| 29 | + | if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 { | |||
| 30 | + | slot = int(accent.Slot) | |||
| 31 | + | } | |||
| 32 | + | resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome) | |||
| 33 | + | return resolved | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // reducedStyles builds the chrome for terminals that cannot show tinted RGB | |||
| 37 | + | // surfaces. Surfaces keep the terminal's default background; hierarchy comes | |||
| 38 | + | // from attributes, and the accent (when known) is only ever a palette slot | |||
| 39 | + | // 0-15 so the terminal's own palette renders it: | |||
| 40 | + | // | |||
| 41 | + | // - active tab, session name, selected rows, hint keys, search selection: | |||
| 42 | + | // bold reverse, so contrast is always the terminal's own fg/bg pair; | |||
| 43 | + | // - copy status, copy selection and the focused pane title: plain reverse | |||
| 44 | + | // (a selection keeps the text's own colors); | |||
| 45 | + | // - inactive tabs and bars: plain; secondary text, muted borders and | |||
| 46 | + | // unfocused titles: faint; | |||
| 47 | + | // - focused borders: bold, in the accent palette slot when there is one; | |||
| 48 | + | // warn borders: bold yellow (slot 3); errors share the focused border. | |||
| 49 | + | // | |||
| 50 | + | // Monochrome uses the same attributes with no color at all. The accent never | |||
| 51 | + | // fills a background: a palette slot is not guaranteed to contrast with the | |||
| 52 | + | // terminal background that reverse video would put on top of it. | |||
| 53 | + | func reducedStyles(accentSlot int, mono bool) Styles { | |||
| 54 | + | plain := renderer.DefaultStyle() | |||
| 55 | + | withFG := func(s renderer.Style, slot int) renderer.Style { | |||
| 56 | + | if !mono && slot >= 0 { | |||
| 57 | + | s.Foreground = slot | |||
| 58 | + | } | |||
| 59 | + | return s | |||
| 60 | + | } | |||
| 61 | + | with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style { | |||
| 62 | + | base.Bold, base.Italic, base.Inverse = bold, italic, inverse | |||
| 63 | + | if faint { | |||
| 64 | + | base.Attrs |= renderer.AttrDim | |||
| 65 | + | } | |||
| 66 | + | return base | |||
| 67 | + | } | |||
| 68 | + | active := with(plain, true, false, true, false) | |||
| 69 | + | activeTitle := with(plain, false, false, true, false) | |||
| 70 | + | selectionMuted := with(plain, false, true, true, false) | |||
| 71 | + | faint := with(plain, false, false, false, true) | |||
| 72 | + | description := with(plain, false, true, false, true) | |||
| 73 | + | focusedBorder := with(withFG(plain, accentSlot), true, false, false, false) | |||
| 74 | + | warn := with(plain, true, true, false, false) | |||
| 75 | + | if !mono { | |||
| 76 | + | warn = with(withFG(plain, 3), true, false, false, false) | |||
| 77 | + | } | |||
| 78 | + | copySelection := with(plain, false, false, true, false) | |||
| 79 | + | ||||
| 80 | + | s := Styles{ | |||
| 81 | + | Reduced: true, | |||
| 82 | + | ||||
| 83 | + | SurfaceBar: plain, | |||
| 84 | + | SurfaceInactive: plain, | |||
| 85 | + | SurfaceRecent: plain, | |||
| 86 | + | SurfaceActive: active, | |||
| 87 | + | BorderMuted: faint, | |||
| 88 | + | BorderActive: focusedBorder, | |||
| 89 | + | BorderWarn: warn, | |||
| 90 | + | NeutralBorder: faint, | |||
| 91 | + | ||||
| 92 | + | TabInactive: plain, | |||
| 93 | + | TabInactiveTitle: faint, | |||
| 94 | + | TabActive: active, | |||
| 95 | + | TabActiveTitle: activeTitle, | |||
| 96 | + | MRURecent: plain, | |||
| 97 | + | ||||
| 98 | + | PickerBase: plain, | |||
| 99 | + | PickerSelection: active, | |||
| 100 | + | HintKey: active, | |||
| 101 | + | PickerDescription: description, | |||
| 102 | + | PickerSeparator: faint, | |||
| 103 | + | PromptBase: plain, | |||
| 104 | + | CopyStatus: with(plain, false, false, true, false), | |||
| 105 | + | SearchSelection: active, | |||
| 106 | + | } | |||
| 107 | + | // Aliases consumed by render paths that have not migrated yet. Selection | |||
| 108 | + | // is the copy-mode selection role and StatusBar the focused pane title | |||
| 109 | + | // bar; both are plain reverse. | |||
| 110 | + | s.StatusBar = copySelection | |||
| 111 | + | s.Accent = s.SurfaceActive | |||
| 112 | + | s.Border = s.BorderMuted | |||
| 113 | + | s.Selection = copySelection | |||
| 114 | + | s.PaletteDesc = description | |||
| 115 | + | s.TabName = s.TabInactive | |||
| 116 | + | s.TabNameActive = s.TabActive | |||
| 117 | + | s.TabTitle = s.TabInactiveTitle | |||
| 118 | + | s.TabTitleActive = s.TabActiveTitle | |||
| 119 | + | s.PickerName = plain | |||
| 120 | + | s.PickerSelectionName = active | |||
| 121 | + | s.PickerSelectionMuted = selectionMuted | |||
| 122 | + | for count := range s.mruStyles { | |||
| 123 | + | for index := 0; index <= count; index++ { | |||
| 124 | + | s.mruStyles[count][index] = plain | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return s | |||
| 128 | + | } | |||
M internal/usecase/theme/neutral_history_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestNeutralHistoryGradient(t *testing.T) { | |
| 11 | - | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 11 | + | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true} | |
| 12 | 12 | styles := NewStyles(theme) | |
| 13 | 13 | require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB) | |
| 14 | 14 | for count := 1; count <= 9; count++ { | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestNeutralHistoryGradient(t *testing.T) { | 10 | func TestNeutralHistoryGradient(t *testing.T) { | ||
| 11 | - | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | 11 | + | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true} |
| 12 | styles := NewStyles(theme) | 12 | styles := NewStyles(theme) | ||
| 13 | require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB) | 13 | require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB) | ||
| 14 | for count := 1; count <= 9; count++ { | 14 | for count := 1; count <= 9; count++ { | ||
M internal/usecase/theme/phase3_review_test.go +3 -4
| @@ -27,7 +27,6 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||
| 27 | 27 | name: "explicit known slot without truecolor remains semantic", | |
| 28 | 28 | theme: func() Theme { | |
| 29 | 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 30 | - | t.TrueColor = false | |
| 31 | 30 | return t | |
| 32 | 31 | }(), | |
| 33 | 32 | policy: explicit, | |
| @@ -37,7 +36,7 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||
| 37 | 36 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | |
| 38 | 37 | theme: Theme{ | |
| 39 | 38 | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 40 | - | HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true, | |
| 39 | + | HasFG: true, HasBG: true, Known: true, SchemeKnown: true, UsePalette: true, | |
| 41 | 40 | }, | |
| 42 | 41 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 43 | 42 | want: Accent{Slot: 12, IndexedOnly: true}, | |
| @@ -45,14 +44,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||
| 45 | 44 | { | |
| 46 | 45 | name: "automatic absent defaults keeps light scheme indexed blue fallback", | |
| 47 | 46 | theme: Theme{ | |
| 48 | - | TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true, | |
| 47 | + | Known: true, SchemeKnown: true, Light: true, UsePalette: true, | |
| 49 | 48 | }, | |
| 50 | 49 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 51 | 50 | want: Accent{Slot: 4, IndexedOnly: true}, | |
| 52 | 51 | }, | |
| 53 | 52 | { | |
| 54 | 53 | name: "automatic absent palette and unknown scheme stays neutral", | |
| 55 | - | theme: Theme{TrueColor: true, Known: true, UsePalette: true}, | |
| 54 | + | theme: Theme{Known: true, UsePalette: true}, | |
| 56 | 55 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 57 | 56 | want: Accent{}, | |
| 58 | 57 | }, | |
| @@ -27,7 +27,6 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||||
| 27 | name: "explicit known slot without truecolor remains semantic", | 27 | name: "explicit known slot without truecolor remains semantic", | ||
| 28 | theme: func() Theme { | 28 | theme: func() Theme { | ||
| 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | ||
| 30 | - | t.TrueColor = false | |||
| 31 | return t | 30 | return t | ||
| 32 | }(), | 31 | }(), | ||
| 33 | policy: explicit, | 32 | policy: explicit, | ||
| @@ -37,7 +36,7 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||||
| 37 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | 36 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | ||
| 38 | theme: Theme{ | 37 | theme: Theme{ | ||
| 39 | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | 38 | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | ||
| 40 | - | HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true, | 39 | + | HasFG: true, HasBG: true, Known: true, SchemeKnown: true, UsePalette: true, |
| 41 | }, | 40 | }, | ||
| 42 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | 41 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | ||
| 43 | want: Accent{Slot: 12, IndexedOnly: true}, | 42 | want: Accent{Slot: 12, IndexedOnly: true}, | ||
| @@ -45,14 +44,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||||
| 45 | { | 44 | { | ||
| 46 | name: "automatic absent defaults keeps light scheme indexed blue fallback", | 45 | name: "automatic absent defaults keeps light scheme indexed blue fallback", | ||
| 47 | theme: Theme{ | 46 | theme: Theme{ | ||
| 48 | - | TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true, | 47 | + | Known: true, SchemeKnown: true, Light: true, UsePalette: true, |
| 49 | }, | 48 | }, | ||
| 50 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | 49 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | ||
| 51 | want: Accent{Slot: 4, IndexedOnly: true}, | 50 | want: Accent{Slot: 4, IndexedOnly: true}, | ||
| 52 | }, | 51 | }, | ||
| 53 | { | 52 | { | ||
| 54 | name: "automatic absent palette and unknown scheme stays neutral", | 53 | name: "automatic absent palette and unknown scheme stays neutral", | ||
| 55 | - | theme: Theme{TrueColor: true, Known: true, UsePalette: true}, | 54 | + | theme: Theme{Known: true, UsePalette: true}, |
| 56 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | 55 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | ||
| 57 | want: Accent{}, | 56 | want: Accent{}, | ||
| 58 | }, | 57 | }, | ||
M internal/usecase/theme/ramp_test.go +3 -5
| @@ -54,7 +54,7 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||
| 54 | 54 | theme := Theme{ | |
| 55 | 55 | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |
| 56 | 56 | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | |
| 57 | - | HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true, | |
| 57 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, | |
| 58 | 58 | } | |
| 59 | 59 | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |
| 60 | 60 | ramp := BuildRamp(theme, accent) | |
| @@ -155,7 +155,6 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||
| 155 | 155 | ||
| 156 | 156 | func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |
| 157 | 157 | theme := rampTheme(false) | |
| 158 | - | theme.TrueColor = false | |
| 159 | 158 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 160 | 159 | theme.PaletteKnown = 1 << 2 | |
| 161 | 160 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| @@ -172,7 +171,6 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |||
| 172 | 171 | }) | |
| 173 | 172 | } | |
| 174 | 173 | ||
| 175 | - | theme.TrueColor = true | |
| 176 | 174 | theme.UsePalette = false | |
| 177 | 175 | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 178 | 176 | require.False(t, off.Accent.Known) | |
| @@ -188,7 +186,7 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |||
| 188 | 186 | ||
| 189 | 187 | func rampTheme(light bool) Theme { | |
| 190 | 188 | if light { | |
| 191 | - | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 189 | + | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true} | |
| 192 | 190 | } | |
| 193 | - | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 191 | + | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true} | |
| 194 | 192 | } | |
| @@ -54,7 +54,7 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||||
| 54 | theme := Theme{ | 54 | theme := Theme{ | ||
| 55 | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | 55 | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | ||
| 56 | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | 56 | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | ||
| 57 | - | HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true, | 57 | + | HasFG: true, HasBG: true, Known: true, UsePalette: true, |
| 58 | } | 58 | } | ||
| 59 | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | 59 | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | ||
| 60 | ramp := BuildRamp(theme, accent) | 60 | ramp := BuildRamp(theme, accent) | ||
| @@ -155,7 +155,6 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||||
| 155 | 155 | ||||
| 156 | func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | 156 | func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | ||
| 157 | theme := rampTheme(false) | 157 | theme := rampTheme(false) | ||
| 158 | - | theme.TrueColor = false | |||
| 159 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | 158 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | ||
| 160 | theme.PaletteKnown = 1 << 2 | 159 | theme.PaletteKnown = 1 << 2 | ||
| 161 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | 160 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | ||
| @@ -172,7 +171,6 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |||||
| 172 | }) | 171 | }) | ||
| 173 | } | 172 | } | ||
| 174 | 173 | ||||
| 175 | - | theme.TrueColor = true | |||
| 176 | theme.UsePalette = false | 174 | theme.UsePalette = false | ||
| 177 | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | 175 | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | ||
| 178 | require.False(t, off.Accent.Known) | 176 | require.False(t, off.Accent.Known) | ||
| @@ -188,7 +186,7 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |||||
| 188 | 186 | ||||
| 189 | func rampTheme(light bool) Theme { | 187 | func rampTheme(light bool) Theme { | ||
| 190 | if light { | 188 | if light { | ||
| 191 | - | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | 189 | + | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true} |
| 192 | } | 190 | } | ||
| 193 | - | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | 191 | + | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true} |
| 194 | } | 192 | } | ||
M internal/usecase/theme/styles.go +12 -0
| @@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style { | |||
| 47 | 47 | return style | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | // PulseAttribute is PulseColor for reduced-color attachments: the glyph's | |
| 51 | + | // on/off beat already pulses, so the visible beat is just bold. Grey indexes | |
| 52 | + | // would quantize to arbitrary slots or vanish. | |
| 53 | + | func PulseAttribute(base renderer.Style) renderer.Style { | |
| 54 | + | base.Bold = true | |
| 55 | + | return base | |
| 56 | + | } | |
| 57 | + | ||
| 50 | 58 | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | |
| 51 | 59 | // background color at the oldest entry. | |
| 52 | 60 | func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |
| @@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||
| 62 | 70 | // Styles is the complete semantic set of terminal chrome styles. The legacy | |
| 63 | 71 | // aliases remain until daemon renderers consume the semantic fields directly. | |
| 64 | 72 | type Styles struct { | |
| 73 | + | // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the | |
| 74 | + | // attention pulse uses bold instead of a grey ramp (see ResolveForColor). | |
| 75 | + | Reduced bool | |
| 76 | + | ||
| 65 | 77 | // mruStyles are all position/count combinations that status composition | |
| 66 | 78 | // can display. They keep OKLab interpolation out of render paths. | |
| 67 | 79 | mruStyles [9][9]renderer.Style | |
| @@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style { | |||||
| 47 | return style | 47 | return style | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | // PulseAttribute is PulseColor for reduced-color attachments: the glyph's | |||
| 51 | + | // on/off beat already pulses, so the visible beat is just bold. Grey indexes | |||
| 52 | + | // would quantize to arbitrary slots or vanish. | |||
| 53 | + | func PulseAttribute(base renderer.Style) renderer.Style { | |||
| 54 | + | base.Bold = true | |||
| 55 | + | return base | |||
| 56 | + | } | |||
| 57 | + | ||||
| 50 | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | 58 | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | ||
| 51 | // background color at the oldest entry. | 59 | // background color at the oldest entry. | ||
| 52 | func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | 60 | func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | ||
| @@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||||
| 62 | // Styles is the complete semantic set of terminal chrome styles. The legacy | 70 | // Styles is the complete semantic set of terminal chrome styles. The legacy | ||
| 63 | // aliases remain until daemon renderers consume the semantic fields directly. | 71 | // aliases remain until daemon renderers consume the semantic fields directly. | ||
| 64 | type Styles struct { | 72 | type Styles struct { | ||
| 73 | + | // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the | |||
| 74 | + | // attention pulse uses bold instead of a grey ramp (see ResolveForColor). | |||
| 75 | + | Reduced bool | |||
| 76 | + | ||||
| 65 | // mruStyles are all position/count combinations that status composition | 77 | // mruStyles are all position/count combinations that status composition | ||
| 66 | // can display. They keep OKLab interpolation out of render paths. | 78 | // can display. They keep OKLab interpolation out of render paths. | ||
| 67 | mruStyles [9][9]renderer.Style | 79 | mruStyles [9][9]renderer.Style | ||
M internal/usecase/theme/theme.go +17 -9
| @@ -19,13 +19,14 @@ type Theme struct { | |||
| 19 | 19 | PaletteKnown uint16 | |
| 20 | 20 | HasFG bool | |
| 21 | 21 | HasBG bool | |
| 22 | - | // TrueColor records output capability for transport compatibility. Semantic | |
| 23 | - | // styles remain RGB; the attachment renderer quantizes them when necessary. | |
| 24 | - | TrueColor bool | |
| 25 | - | Known bool | |
| 26 | - | SchemeKnown bool | |
| 27 | - | Light bool | |
| 28 | - | UsePalette bool | |
| 22 | + | Known bool | |
| 23 | + | SchemeKnown bool | |
| 24 | + | Light bool | |
| 25 | + | UsePalette bool | |
| 26 | + | // DimByAttribute makes Dimmer use the faint attribute instead of blending | |
| 27 | + | // colors. The daemon sets it per attachment for ANSI16 and monochrome | |
| 28 | + | // output, where RGB blends would quantize badly or vanish. | |
| 29 | + | DimByAttribute bool | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // PaletteColor returns a palette color only when palette inheritance is | |
| @@ -43,7 +44,6 @@ var ( | |||
| 43 | 44 | Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18}, | |
| 44 | 45 | HasFG: true, | |
| 45 | 46 | HasBG: true, | |
| 46 | - | TrueColor: true, | |
| 47 | 47 | Known: true, | |
| 48 | 48 | SchemeKnown: true, | |
| 49 | 49 | Light: false, | |
| @@ -53,7 +53,6 @@ var ( | |||
| 53 | 53 | Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, | |
| 54 | 54 | HasFG: true, | |
| 55 | 55 | HasBG: true, | |
| 56 | - | TrueColor: true, | |
| 57 | 56 | Known: true, | |
| 58 | 57 | SchemeKnown: true, | |
| 59 | 58 | Light: true, | |
| @@ -448,6 +447,11 @@ func WithForegroundDimming(percent int) DimmerOption { | |||
| 448 | 447 | } | |
| 449 | 448 | ||
| 450 | 449 | // NewDimmer returns a reusable dimmer with optional per-channel overrides. | |
| 450 | + | // | |
| 451 | + | // Keep this function inlinable: it sits on the per-cell render path, and adding | |
| 452 | + | // fields or work pushes it over the inline budget (80). After changing it, | |
| 453 | + | // check `go build -gcflags=-m=2 ./internal/usecase/theme 2>&1 | grep NewDimmer` | |
| 454 | + | // and rerun BenchmarkComposeCapturedFrame in internal/usecase/daemon. | |
| 451 | 455 | func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | |
| 452 | 456 | d := Dimmer{ | |
| 453 | 457 | theme: t, | |
| @@ -470,6 +474,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | |||
| 470 | 474 | // Dim fades a style's resolved background toward the terminal background, | |
| 471 | 475 | // then fades its foreground and custom underline toward that dimmed background. | |
| 472 | 476 | func (d Dimmer) Dim(style renderer.Style) renderer.Style { | |
| 477 | + | if d.theme.DimByAttribute { | |
| 478 | + | style.Attrs |= renderer.AttrDim | |
| 479 | + | return style | |
| 480 | + | } | |
| 473 | 481 | if !usable(d.theme) { | |
| 474 | 482 | return style | |
| 475 | 483 | } | |
| @@ -19,13 +19,14 @@ type Theme struct { | |||||
| 19 | PaletteKnown uint16 | 19 | PaletteKnown uint16 | ||
| 20 | HasFG bool | 20 | HasFG bool | ||
| 21 | HasBG bool | 21 | HasBG bool | ||
| 22 | - | // TrueColor records output capability for transport compatibility. Semantic | 22 | + | Known bool |
| 23 | - | // styles remain RGB; the attachment renderer quantizes them when necessary. | 23 | + | SchemeKnown bool |
| 24 | - | TrueColor bool | 24 | + | Light bool |
| 25 | - | Known bool | 25 | + | UsePalette bool |
| 26 | - | SchemeKnown bool | 26 | + | // DimByAttribute makes Dimmer use the faint attribute instead of blending |
| 27 | - | Light bool | 27 | + | // colors. The daemon sets it per attachment for ANSI16 and monochrome |
| 28 | - | UsePalette bool | 28 | + | // output, where RGB blends would quantize badly or vanish. |
| 29 | + | DimByAttribute bool | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // PaletteColor returns a palette color only when palette inheritance is | 32 | // PaletteColor returns a palette color only when palette inheritance is | ||
| @@ -43,7 +44,6 @@ var ( | |||||
| 43 | Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18}, | 44 | Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18}, | ||
| 44 | HasFG: true, | 45 | HasFG: true, | ||
| 45 | HasBG: true, | 46 | HasBG: true, | ||
| 46 | - | TrueColor: true, | |||
| 47 | Known: true, | 47 | Known: true, | ||
| 48 | SchemeKnown: true, | 48 | SchemeKnown: true, | ||
| 49 | Light: false, | 49 | Light: false, | ||
| @@ -53,7 +53,6 @@ var ( | |||||
| 53 | Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, | 53 | Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, | ||
| 54 | HasFG: true, | 54 | HasFG: true, | ||
| 55 | HasBG: true, | 55 | HasBG: true, | ||
| 56 | - | TrueColor: true, | |||
| 57 | Known: true, | 56 | Known: true, | ||
| 58 | SchemeKnown: true, | 57 | SchemeKnown: true, | ||
| 59 | Light: true, | 58 | Light: true, | ||
| @@ -448,6 +447,11 @@ func WithForegroundDimming(percent int) DimmerOption { | |||||
| 448 | } | 447 | } | ||
| 449 | 448 | ||||
| 450 | // NewDimmer returns a reusable dimmer with optional per-channel overrides. | 449 | // NewDimmer returns a reusable dimmer with optional per-channel overrides. | ||
| 450 | + | // | |||
| 451 | + | // Keep this function inlinable: it sits on the per-cell render path, and adding | |||
| 452 | + | // fields or work pushes it over the inline budget (80). After changing it, | |||
| 453 | + | // check `go build -gcflags=-m=2 ./internal/usecase/theme 2>&1 | grep NewDimmer` | |||
| 454 | + | // and rerun BenchmarkComposeCapturedFrame in internal/usecase/daemon. | |||
| 451 | func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | 455 | func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | ||
| 452 | d := Dimmer{ | 456 | d := Dimmer{ | ||
| 453 | theme: t, | 457 | theme: t, | ||
| @@ -470,6 +474,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | |||||
| 470 | // Dim fades a style's resolved background toward the terminal background, | 474 | // Dim fades a style's resolved background toward the terminal background, | ||
| 471 | // then fades its foreground and custom underline toward that dimmed background. | 475 | // then fades its foreground and custom underline toward that dimmed background. | ||
| 472 | func (d Dimmer) Dim(style renderer.Style) renderer.Style { | 476 | func (d Dimmer) Dim(style renderer.Style) renderer.Style { | ||
| 477 | + | if d.theme.DimByAttribute { | |||
| 478 | + | style.Attrs |= renderer.AttrDim | |||
| 479 | + | return style | |||
| 480 | + | } | |||
| 473 | if !usable(d.theme) { | 481 | if !usable(d.theme) { | ||
| 474 | return style | 482 | return style | ||
| 475 | } | 483 | } | ||
M internal/usecase/theme/theme_test.go +7 -9
| @@ -271,8 +271,7 @@ func TestDimmer(t *testing.T) { | |||
| 271 | 271 | theme := Theme{ | |
| 272 | 272 | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 273 | 273 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 274 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 275 | - | } | |
| 274 | + | HasFG: true, HasBG: true, Known: true} | |
| 276 | 275 | dimmer := NewDimmer(theme, WithForegroundDimming(55)) | |
| 277 | 276 | ||
| 278 | 277 | t.Run("fades foreground toward the faded cell background", func(t *testing.T) { | |
| @@ -344,7 +343,7 @@ func TestDimmer(t *testing.T) { | |||
| 344 | 343 | } | |
| 345 | 344 | ||
| 346 | 345 | func TestDimmerDefaults(t *testing.T) { | |
| 347 | - | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 346 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} | |
| 348 | 347 | tests := []struct { | |
| 349 | 348 | name string | |
| 350 | 349 | in renderer.Style | |
| @@ -439,8 +438,7 @@ func TestDimmerUsesReportedPalette(t *testing.T) { | |||
| 439 | 438 | foreground := renderer.RGB{R: 200, G: 200, B: 200} | |
| 440 | 439 | baseTheme := Theme{ | |
| 441 | 440 | Foreground: foreground, Background: background, | |
| 442 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 443 | - | } | |
| 441 | + | HasFG: true, HasBG: true, Known: true} | |
| 444 | 442 | ||
| 445 | 443 | reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75} | |
| 446 | 444 | reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200} | |
| @@ -585,7 +583,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||
| 585 | 583 | t.Fatalf("selection fallback=%+v want inverse", got) | |
| 586 | 584 | } | |
| 587 | 585 | ||
| 588 | - | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 586 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} | |
| 589 | 587 | if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want { | |
| 590 | 588 | t.Fatalf("blend=%+v want %+v", got, want) | |
| 591 | 589 | } | |
| @@ -608,7 +606,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||
| 608 | 606 | } | |
| 609 | 607 | ||
| 610 | 608 | func TestEmphasisStyle(t *testing.T) { | |
| 611 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 609 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} | |
| 612 | 610 | base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}} | |
| 613 | 611 | ||
| 614 | 612 | tests := []struct { | |
| @@ -646,7 +644,7 @@ func TestEmphasisStyle(t *testing.T) { | |||
| 646 | 644 | } | |
| 647 | 645 | ||
| 648 | 646 | func TestMutedVariantStyle(t *testing.T) { | |
| 649 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 647 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} | |
| 650 | 648 | rgbBase := renderer.Style{ | |
| 651 | 649 | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 652 | 650 | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | |
| @@ -686,7 +684,7 @@ func TestMutedVariantStyle(t *testing.T) { | |||
| 686 | 684 | { | |
| 687 | 685 | name: "base without RGB background and theme without background returns base unchanged", | |
| 688 | 686 | base: fgOnlyBase, | |
| 689 | - | theme: Theme{Known: true, TrueColor: true, HasFG: true}, | |
| 687 | + | theme: Theme{Known: true, HasFG: true}, | |
| 690 | 688 | want: fgOnlyBase, | |
| 691 | 689 | }, | |
| 692 | 690 | { | |
| @@ -271,8 +271,7 @@ func TestDimmer(t *testing.T) { | |||||
| 271 | theme := Theme{ | 271 | theme := Theme{ | ||
| 272 | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | 272 | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | ||
| 273 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | 273 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | ||
| 274 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | 274 | + | HasFG: true, HasBG: true, Known: true} |
| 275 | - | } | |||
| 276 | dimmer := NewDimmer(theme, WithForegroundDimming(55)) | 275 | dimmer := NewDimmer(theme, WithForegroundDimming(55)) | ||
| 277 | 276 | ||||
| 278 | t.Run("fades foreground toward the faded cell background", func(t *testing.T) { | 277 | t.Run("fades foreground toward the faded cell background", func(t *testing.T) { | ||
| @@ -344,7 +343,7 @@ func TestDimmer(t *testing.T) { | |||||
| 344 | } | 343 | } | ||
| 345 | 344 | ||||
| 346 | func TestDimmerDefaults(t *testing.T) { | 345 | func TestDimmerDefaults(t *testing.T) { | ||
| 347 | - | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | 346 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} |
| 348 | tests := []struct { | 347 | tests := []struct { | ||
| 349 | name string | 348 | name string | ||
| 350 | in renderer.Style | 349 | in renderer.Style | ||
| @@ -439,8 +438,7 @@ func TestDimmerUsesReportedPalette(t *testing.T) { | |||||
| 439 | foreground := renderer.RGB{R: 200, G: 200, B: 200} | 438 | foreground := renderer.RGB{R: 200, G: 200, B: 200} | ||
| 440 | baseTheme := Theme{ | 439 | baseTheme := Theme{ | ||
| 441 | Foreground: foreground, Background: background, | 440 | Foreground: foreground, Background: background, | ||
| 442 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | 441 | + | HasFG: true, HasBG: true, Known: true} |
| 443 | - | } | |||
| 444 | 442 | ||||
| 445 | reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75} | 443 | reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75} | ||
| 446 | reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200} | 444 | reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200} | ||
| @@ -585,7 +583,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||||
| 585 | t.Fatalf("selection fallback=%+v want inverse", got) | 583 | t.Fatalf("selection fallback=%+v want inverse", got) | ||
| 586 | } | 584 | } | ||
| 587 | 585 | ||||
| 588 | - | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | 586 | + | theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} |
| 589 | if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want { | 587 | if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want { | ||
| 590 | t.Fatalf("blend=%+v want %+v", got, want) | 588 | t.Fatalf("blend=%+v want %+v", got, want) | ||
| 591 | } | 589 | } | ||
| @@ -608,7 +606,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) { | |||||
| 608 | } | 606 | } | ||
| 609 | 607 | ||||
| 610 | func TestEmphasisStyle(t *testing.T) { | 608 | func TestEmphasisStyle(t *testing.T) { | ||
| 611 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | 609 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} |
| 612 | base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}} | 610 | base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}} | ||
| 613 | 611 | ||||
| 614 | tests := []struct { | 612 | tests := []struct { | ||
| @@ -646,7 +644,7 @@ func TestEmphasisStyle(t *testing.T) { | |||||
| 646 | } | 644 | } | ||
| 647 | 645 | ||||
| 648 | func TestMutedVariantStyle(t *testing.T) { | 646 | func TestMutedVariantStyle(t *testing.T) { | ||
| 649 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | 647 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true} |
| 650 | rgbBase := renderer.Style{ | 648 | rgbBase := renderer.Style{ | ||
| 651 | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}, | 649 | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100}, | ||
| 652 | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | 650 | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0}, | ||
| @@ -686,7 +684,7 @@ func TestMutedVariantStyle(t *testing.T) { | |||||
| 686 | { | 684 | { | ||
| 687 | name: "base without RGB background and theme without background returns base unchanged", | 685 | name: "base without RGB background and theme without background returns base unchanged", | ||
| 688 | base: fgOnlyBase, | 686 | base: fgOnlyBase, | ||
| 689 | - | theme: Theme{Known: true, TrueColor: true, HasFG: true}, | 687 | + | theme: Theme{Known: true, HasFG: true}, |
| 690 | want: fgOnlyBase, | 688 | want: fgOnlyBase, | ||
| 691 | }, | 689 | }, | ||
| 692 | { | 690 | { | ||
M transport_replay_integration_test.go +1 -1
| @@ -60,7 +60,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||
| 60 | 60 | cleared := protocol.Theme{ | |
| 61 | 61 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 62 | 62 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 63 | - | TrueColor: true, SchemeKnown: true, | |
| 63 | + | SchemeKnown: true, | |
| 64 | 64 | } | |
| 65 | 65 | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | |
| 66 | 66 | definitive := cleared | |
| @@ -60,7 +60,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) { | |||||
| 60 | cleared := protocol.Theme{ | 60 | cleared := protocol.Theme{ | ||
| 61 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 61 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
| 62 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | 62 | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | ||
| 63 | - | TrueColor: true, SchemeKnown: true, | 63 | + | SchemeKnown: true, |
| 64 | } | 64 | } | ||
| 65 | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | 65 | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | ||
| 66 | definitive := cleared | 66 | definitive := cleared | ||