Merge pull request 'feat(status): show recent-session ranks while Ctrl is held and fade MRU text' (#26) from feat/mru-text-dimming into main
Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/26
32 files · +1342 -160
M docs/architecture.md +2 -0
| @@ -98,6 +98,8 @@ On remote attachments the client predicts local echo, after mosh. The attached l | |||
| 98 | 98 | ||
| 99 | 99 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | |
| 100 | 100 | ||
| 101 | + | With the kitty keyboard protocol the client asks its terminal for every key event, modifier presses and releases included (`kittykey.RequestFlags`). The input pump runs a `kittykey.Normalizer` that rewrites that stream back to the `OuterFlags` encoding every consumer reads, so routers, pickers, and panes see the same bytes as before, and tracks whether Ctrl is held on its own. The attached loop reports a hold that lasted 150 ms, and its release, as `ModifierState`; the daemon keeps it per attachment and prefixes the status-bar history with its Ctrl+N ranks while it is held. | |
| 102 | + | ||
| 101 | 103 | ## Broker-owned hosts and daemon composition | |
| 102 | 104 | ||
| 103 | 105 | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | |
| @@ -98,6 +98,8 @@ On remote attachments the client predicts local echo, after mosh. The attached l | |||||
| 98 | 98 | ||||
| 99 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | 99 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | ||
| 100 | 100 | ||||
| 101 | + | With the kitty keyboard protocol the client asks its terminal for every key event, modifier presses and releases included (`kittykey.RequestFlags`). The input pump runs a `kittykey.Normalizer` that rewrites that stream back to the `OuterFlags` encoding every consumer reads, so routers, pickers, and panes see the same bytes as before, and tracks whether Ctrl is held on its own. The attached loop reports a hold that lasted 150 ms, and its release, as `ModifierState`; the daemon keeps it per attachment and prefixes the status-bar history with its Ctrl+N ranks while it is held. | |||
| 102 | + | ||||
| 101 | ## Broker-owned hosts and daemon composition | 103 | ## Broker-owned hosts and daemon composition | ||
| 102 | 104 | ||||
| 103 | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | 105 | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | ||
M docs/protocol.md +1 -1
| @@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection` | |||
| 12 | 12 | ||
| 13 | 13 | Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`, | |
| 14 | 14 | `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`, | |
| 15 | - | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`, | |
| 15 | + | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`, | |
| 16 | 16 | `RouteAttentionSubscription`, `SamePeerSwitchRequest`, | |
| 17 | 17 | `RecentRouteSnapshot`, `RouteNavigationFailure`, | |
| 18 | 18 | `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`, | |
| @@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection` | |||||
| 12 | 12 | ||||
| 13 | Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`, | 13 | Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`, | ||
| 14 | `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`, | 14 | `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`, | ||
| 15 | - | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`, | 15 | + | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `ModifierState`, `RemotePreviewWatch`, |
| 16 | `RouteAttentionSubscription`, `SamePeerSwitchRequest`, | 16 | `RouteAttentionSubscription`, `SamePeerSwitchRequest`, | ||
| 17 | `RecentRouteSnapshot`, `RouteNavigationFailure`, | 17 | `RecentRouteSnapshot`, `RouteNavigationFailure`, | ||
| 18 | `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`, | 18 | `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`, | ||
M docs/terminal.md +1 -1
| @@ -97,7 +97,7 @@ Terminals without Kitty graphics get text only and one warning. | |||
| 97 | 97 | ||
| 98 | 98 | ## Kitty keyboard | |
| 99 | 99 | ||
| 100 | - | When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. | |
| 100 | + | When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. Hold Ctrl on its own to see each recent session's number in the status bar, for example `1:local 2:neferwl@arch`. | |
| 101 | 101 | ||
| 102 | 102 | - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged. | |
| 103 | 103 | - It works with AZERTY and other layouts. | |
| @@ -97,7 +97,7 @@ Terminals without Kitty graphics get text only and one warning. | |||||
| 97 | 97 | ||||
| 98 | ## Kitty keyboard | 98 | ## Kitty keyboard | ||
| 99 | 99 | ||||
| 100 | - | When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. | 100 | + | When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghostty, WezTerm, recent Alacritty), vev enables it at startup. Ctrl+1 to Ctrl+9 then switch to the 1st to 9th previous session in the status-bar history. Ctrl+1 goes back to the session you used last. Hold Ctrl on its own to see each recent session's number in the status bar, for example `1:local 2:neferwl@arch`. |
| 101 | 101 | ||||
| 102 | - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged. | 102 | - Detection uses the same short query as Kitty graphics. Other terminals keep their usual keys, and Ctrl+digits reach the pane unchanged. | ||
| 103 | - It works with AZERTY and other layouts. | 103 | - It works with AZERTY and other layouts. | ||
M internal/adapters/sessionwire/proto_convert_test.go +35 -0
| @@ -346,6 +346,41 @@ func TestProtoTerminalFocusWire(t *testing.T) { | |||
| 346 | 346 | require.Error(t, err, "unknown focus is never encoded") | |
| 347 | 347 | } | |
| 348 | 348 | ||
| 349 | + | // TestProtoModifierStateWire pins the ModifierState payload byte for byte and | |
| 350 | + | // refuses a truncated prefix and trailing garbage. | |
| 351 | + | func TestProtoModifierStateWire(t *testing.T) { | |
| 352 | + | tests := []struct { | |
| 353 | + | name string | |
| 354 | + | ctrl bool | |
| 355 | + | want []byte | |
| 356 | + | }{ | |
| 357 | + | // field 34 (length-delimited) -> ModifierState{field 1 = ctrl}. | |
| 358 | + | {name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}}, | |
| 359 | + | {name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}}, | |
| 360 | + | } | |
| 361 | + | for _, tt := range tests { | |
| 362 | + | t.Run(tt.name, func(t *testing.T) { | |
| 363 | + | message := protocol.ModifierState{Ctrl: tt.ctrl} | |
| 364 | + | envelope, err := encodeProtoClient(message) | |
| 365 | + | require.NoError(t, err) | |
| 366 | + | raw, err := proto.Marshal(envelope) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | require.Equal(t, tt.want, raw) | |
| 369 | + | ||
| 370 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |
| 371 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |
| 372 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |
| 373 | + | decoded := &wire.ClientEnvelope{} | |
| 374 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |
| 375 | + | got, err := decodeProtoClient(decoded) | |
| 376 | + | require.NoError(t, err) | |
| 377 | + | require.Equal(t, message, got) | |
| 378 | + | }) | |
| 379 | + | } | |
| 380 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}}) | |
| 381 | + | require.Error(t, err, "a missing payload is never decoded") | |
| 382 | + | } | |
| 383 | + | ||
| 349 | 384 | func protoMessageName(message any) string { | |
| 350 | 385 | return protoShortName(message) | |
| 351 | 386 | } | |
| @@ -346,6 +346,41 @@ func TestProtoTerminalFocusWire(t *testing.T) { | |||||
| 346 | require.Error(t, err, "unknown focus is never encoded") | 346 | require.Error(t, err, "unknown focus is never encoded") | ||
| 347 | } | 347 | } | ||
| 348 | 348 | ||||
| 349 | + | // TestProtoModifierStateWire pins the ModifierState payload byte for byte and | |||
| 350 | + | // refuses a truncated prefix and trailing garbage. | |||
| 351 | + | func TestProtoModifierStateWire(t *testing.T) { | |||
| 352 | + | tests := []struct { | |||
| 353 | + | name string | |||
| 354 | + | ctrl bool | |||
| 355 | + | want []byte | |||
| 356 | + | }{ | |||
| 357 | + | // field 34 (length-delimited) -> ModifierState{field 1 = ctrl}. | |||
| 358 | + | {name: "ctrl held", ctrl: true, want: []byte{0x92, 0x02, 0x02, 0x08, 0x01}}, | |||
| 359 | + | {name: "released", ctrl: false, want: []byte{0x92, 0x02, 0x00}}, | |||
| 360 | + | } | |||
| 361 | + | for _, tt := range tests { | |||
| 362 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 363 | + | message := protocol.ModifierState{Ctrl: tt.ctrl} | |||
| 364 | + | envelope, err := encodeProtoClient(message) | |||
| 365 | + | require.NoError(t, err) | |||
| 366 | + | raw, err := proto.Marshal(envelope) | |||
| 367 | + | require.NoError(t, err) | |||
| 368 | + | require.Equal(t, tt.want, raw) | |||
| 369 | + | ||||
| 370 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |||
| 371 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |||
| 372 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |||
| 373 | + | decoded := &wire.ClientEnvelope{} | |||
| 374 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |||
| 375 | + | got, err := decodeProtoClient(decoded) | |||
| 376 | + | require.NoError(t, err) | |||
| 377 | + | require.Equal(t, message, got) | |||
| 378 | + | }) | |||
| 379 | + | } | |||
| 380 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{}}) | |||
| 381 | + | require.Error(t, err, "a missing payload is never decoded") | |||
| 382 | + | } | |||
| 383 | + | ||||
| 349 | func protoMessageName(message any) string { | 384 | func protoMessageName(message any) string { | ||
| 350 | return protoShortName(message) | 385 | return protoShortName(message) | ||
| 351 | } | 386 | } | ||
M internal/adapters/sessionwire/proto_envelope.go +12 -0
| @@ -170,6 +170,13 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||
| 170 | 170 | return nil, ErrInvalidMessage | |
| 171 | 171 | } | |
| 172 | 172 | return encodeProtoClient(*m) | |
| 173 | + | case protocol.ModifierState: | |
| 174 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{ModifierState: &wire.ModifierState{Ctrl: m.Ctrl}}}, nil | |
| 175 | + | case *protocol.ModifierState: | |
| 176 | + | if m == nil { | |
| 177 | + | return nil, ErrInvalidMessage | |
| 178 | + | } | |
| 179 | + | return encodeProtoClient(*m) | |
| 173 | 180 | case protocol.UIFence: | |
| 174 | 181 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | |
| 175 | 182 | case *protocol.UIFence: | |
| @@ -656,6 +663,11 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 656 | 663 | return selectTabFromWire(payload.SelectTab) | |
| 657 | 664 | case *wire.ClientEnvelope_TerminalFocus: | |
| 658 | 665 | return terminalFocusFromWire(payload.TerminalFocus) | |
| 666 | + | case *wire.ClientEnvelope_ModifierState: | |
| 667 | + | if payload.ModifierState == nil { | |
| 668 | + | return nil, errProtoConvertRange | |
| 669 | + | } | |
| 670 | + | return protocol.ModifierState{Ctrl: payload.ModifierState.GetCtrl()}, nil | |
| 659 | 671 | case *wire.ClientEnvelope_RemotePreviewWatch: | |
| 660 | 672 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | |
| 661 | 673 | case *wire.ClientEnvelope_RouteAttentionSubscription: | |
| @@ -170,6 +170,13 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||||
| 170 | return nil, ErrInvalidMessage | 170 | return nil, ErrInvalidMessage | ||
| 171 | } | 171 | } | ||
| 172 | return encodeProtoClient(*m) | 172 | return encodeProtoClient(*m) | ||
| 173 | + | case protocol.ModifierState: | |||
| 174 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ModifierState{ModifierState: &wire.ModifierState{Ctrl: m.Ctrl}}}, nil | |||
| 175 | + | case *protocol.ModifierState: | |||
| 176 | + | if m == nil { | |||
| 177 | + | return nil, ErrInvalidMessage | |||
| 178 | + | } | |||
| 179 | + | return encodeProtoClient(*m) | |||
| 173 | case protocol.UIFence: | 180 | case protocol.UIFence: | ||
| 174 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | 181 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | ||
| 175 | case *protocol.UIFence: | 182 | case *protocol.UIFence: | ||
| @@ -656,6 +663,11 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||||
| 656 | return selectTabFromWire(payload.SelectTab) | 663 | return selectTabFromWire(payload.SelectTab) | ||
| 657 | case *wire.ClientEnvelope_TerminalFocus: | 664 | case *wire.ClientEnvelope_TerminalFocus: | ||
| 658 | return terminalFocusFromWire(payload.TerminalFocus) | 665 | return terminalFocusFromWire(payload.TerminalFocus) | ||
| 666 | + | case *wire.ClientEnvelope_ModifierState: | |||
| 667 | + | if payload.ModifierState == nil { | |||
| 668 | + | return nil, errProtoConvertRange | |||
| 669 | + | } | |||
| 670 | + | return protocol.ModifierState{Ctrl: payload.ModifierState.GetCtrl()}, nil | |||
| 659 | case *wire.ClientEnvelope_RemotePreviewWatch: | 671 | case *wire.ClientEnvelope_RemotePreviewWatch: | ||
| 660 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | 672 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | ||
| 661 | case *wire.ClientEnvelope_RouteAttentionSubscription: | 673 | case *wire.ClientEnvelope_RouteAttentionSubscription: | ||
M internal/protocol/messages.go +1 -0
| @@ -21,6 +21,7 @@ func (CommandRequest) clientMessage() {} | |||
| 21 | 21 | func (OutputResetRequest) clientMessage() {} | |
| 22 | 22 | func (SelectTab) clientMessage() {} | |
| 23 | 23 | func (TerminalFocus) clientMessage() {} | |
| 24 | + | func (ModifierState) clientMessage() {} | |
| 24 | 25 | func (RemotePreviewWatch) clientMessage() {} | |
| 25 | 26 | func (RouteAttentionSubscription) clientMessage() {} | |
| 26 | 27 | func (SamePeerSwitchRequest) clientMessage() {} | |
| @@ -21,6 +21,7 @@ func (CommandRequest) clientMessage() {} | |||||
| 21 | func (OutputResetRequest) clientMessage() {} | 21 | func (OutputResetRequest) clientMessage() {} | ||
| 22 | func (SelectTab) clientMessage() {} | 22 | func (SelectTab) clientMessage() {} | ||
| 23 | func (TerminalFocus) clientMessage() {} | 23 | func (TerminalFocus) clientMessage() {} | ||
| 24 | + | func (ModifierState) clientMessage() {} | |||
| 24 | func (RemotePreviewWatch) clientMessage() {} | 25 | func (RemotePreviewWatch) clientMessage() {} | ||
| 25 | func (RouteAttentionSubscription) clientMessage() {} | 26 | func (RouteAttentionSubscription) clientMessage() {} | ||
| 26 | func (SamePeerSwitchRequest) clientMessage() {} | 27 | func (SamePeerSwitchRequest) clientMessage() {} | ||
M internal/protocol/session.go +8 -1
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 18 | 18 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 19 | 19 | // version as the session and broker conversations. | |
| 20 | - | const Version uint16 = 66 | |
| 20 | + | const Version uint16 = 67 | |
| 21 | 21 | ||
| 22 | 22 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 23 | 23 | // first committed publication. It excludes the preceding client-local | |
| @@ -401,6 +401,13 @@ func (m TerminalFocus) Validate() error { | |||
| 401 | 401 | return nil | |
| 402 | 402 | } | |
| 403 | 403 | ||
| 404 | + | // ModifierState reports the modifier keys held on their own in the client's | |
| 405 | + | // terminal. A client sends it only when its terminal reports modifier key | |
| 406 | + | // events, so an attachment that never sends it shows no modifier held. | |
| 407 | + | type ModifierState struct { | |
| 408 | + | Ctrl bool | |
| 409 | + | } | |
| 410 | + | ||
| 404 | 411 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | |
| 405 | 412 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | |
| 406 | 413 | } | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | // daemonmux physical preamble conversation (multiplex.proto), whose | 17 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 18 | // negotiated ceilings and accepted daemon binding ride the same wire | 18 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 19 | // version as the session and broker conversations. | 19 | // version as the session and broker conversations. | ||
| 20 | - | const Version uint16 = 66 | 20 | + | const Version uint16 = 67 |
| 21 | 21 | ||||
| 22 | // HandshakeTimeout bounds every transport handshake from connect through the | 22 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 23 | // first committed publication. It excludes the preceding client-local | 23 | // first committed publication. It excludes the preceding client-local | ||
| @@ -401,6 +401,13 @@ func (m TerminalFocus) Validate() error { | |||||
| 401 | return nil | 401 | return nil | ||
| 402 | } | 402 | } | ||
| 403 | 403 | ||||
| 404 | + | // ModifierState reports the modifier keys held on their own in the client's | |||
| 405 | + | // terminal. A client sends it only when its terminal reports modifier key | |||
| 406 | + | // events, so an attachment that never sends it shows no modifier held. | |||
| 407 | + | type ModifierState struct { | |||
| 408 | + | Ctrl bool | |||
| 409 | + | } | |||
| 410 | + | ||||
| 404 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | 411 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | ||
| 405 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | 412 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | ||
| 406 | } | 413 | } | ||
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(66), Version) | |
| 13 | + | require.Equal(t, uint16(67), 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(66), Version) | 13 | + | require.Equal(t, uint16(67), 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/envelope.pb.go +84 -64
| @@ -360,6 +360,7 @@ type ClientEnvelope struct { | |||
| 360 | 360 | // *ClientEnvelope_SelectTab | |
| 361 | 361 | // *ClientEnvelope_RemotePreviewWatch | |
| 362 | 362 | // *ClientEnvelope_TerminalFocus | |
| 363 | + | // *ClientEnvelope_ModifierState | |
| 363 | 364 | Payload isClientEnvelope_Payload `protobuf_oneof:"payload"` | |
| 364 | 365 | unknownFields protoimpl.UnknownFields | |
| 365 | 366 | sizeCache protoimpl.SizeCache | |
| @@ -663,6 +664,15 @@ func (x *ClientEnvelope) GetTerminalFocus() *TerminalFocus { | |||
| 663 | 664 | return nil | |
| 664 | 665 | } | |
| 665 | 666 | ||
| 667 | + | func (x *ClientEnvelope) GetModifierState() *ModifierState { | |
| 668 | + | if x != nil { | |
| 669 | + | if x, ok := x.Payload.(*ClientEnvelope_ModifierState); ok { | |
| 670 | + | return x.ModifierState | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | return nil | |
| 674 | + | } | |
| 675 | + | ||
| 666 | 676 | type isClientEnvelope_Payload interface { | |
| 667 | 677 | isClientEnvelope_Payload() | |
| 668 | 678 | } | |
| @@ -783,6 +793,10 @@ type ClientEnvelope_TerminalFocus struct { | |||
| 783 | 793 | TerminalFocus *TerminalFocus `protobuf:"bytes,33,opt,name=terminal_focus,json=terminalFocus,proto3,oneof"` | |
| 784 | 794 | } | |
| 785 | 795 | ||
| 796 | + | type ClientEnvelope_ModifierState struct { | |
| 797 | + | ModifierState *ModifierState `protobuf:"bytes,34,opt,name=modifier_state,json=modifierState,proto3,oneof"` | |
| 798 | + | } | |
| 799 | + | ||
| 786 | 800 | func (*ClientEnvelope_Hello) isClientEnvelope_Payload() {} | |
| 787 | 801 | ||
| 788 | 802 | func (*ClientEnvelope_Input) isClientEnvelope_Payload() {} | |
| @@ -841,6 +855,8 @@ func (*ClientEnvelope_RemotePreviewWatch) isClientEnvelope_Payload() {} | |||
| 841 | 855 | ||
| 842 | 856 | func (*ClientEnvelope_TerminalFocus) isClientEnvelope_Payload() {} | |
| 843 | 857 | ||
| 858 | + | func (*ClientEnvelope_ModifierState) isClientEnvelope_Payload() {} | |
| 859 | + | ||
| 844 | 860 | // ServerEnvelope is the closed server-to-client union. Exactly one variant | |
| 845 | 861 | // must be set per envelope. | |
| 846 | 862 | type ServerEnvelope struct { | |
| @@ -1383,7 +1399,7 @@ const file_envelope_proto_rawDesc = "" + | |||
| 1383 | 1399 | "\x11output_data_limit\x18\x06 \x01(\x04R\x0foutputDataLimit\x12'\n" + | |
| 1384 | 1400 | "\x0fcapability_bits\x18\a \x01(\rR\x0ecapabilityBits\x12\x1a\n" + | |
| 1385 | 1401 | "\baccepted\x18\b \x01(\bR\baccepted\x12@\n" + | |
| 1386 | - | "\trejection\x18\t \x01(\v2\".vev.wire.v1.PreambleRejectionCodeR\trejection\"\xb0\x10\n" + | |
| 1402 | + | "\trejection\x18\t \x01(\v2\".vev.wire.v1.PreambleRejectionCodeR\trejection\"\xf5\x10\n" + | |
| 1387 | 1403 | "\x0eClientEnvelope\x12*\n" + | |
| 1388 | 1404 | "\x05hello\x18\x01 \x01(\v2\x12.vev.wire.v1.HelloH\x00R\x05hello\x12*\n" + | |
| 1389 | 1405 | "\x05input\x18\x02 \x01(\v2\x12.vev.wire.v1.InputH\x00R\x05input\x12-\n" + | |
| @@ -1416,7 +1432,8 @@ const file_envelope_proto_rawDesc = "" + | |||
| 1416 | 1432 | "\n" + | |
| 1417 | 1433 | "select_tab\x18\x1f \x01(\v2\x16.vev.wire.v1.SelectTabH\x00R\tselectTab\x12S\n" + | |
| 1418 | 1434 | "\x14remote_preview_watch\x18 \x01(\v2\x1f.vev.wire.v1.RemotePreviewWatchH\x00R\x12remotePreviewWatch\x12C\n" + | |
| 1419 | - | "\x0eterminal_focus\x18! \x01(\v2\x1a.vev.wire.v1.TerminalFocusH\x00R\rterminalFocusB\t\n" + | |
| 1435 | + | "\x0eterminal_focus\x18! \x01(\v2\x1a.vev.wire.v1.TerminalFocusH\x00R\rterminalFocus\x12C\n" + | |
| 1436 | + | "\x0emodifier_state\x18\" \x01(\v2\x1a.vev.wire.v1.ModifierStateH\x00R\rmodifierStateB\t\n" + | |
| 1420 | 1437 | "\apayloadJ\x04\b\x0e\x10\x0fJ\x04\b\x1b\x10\x1c\"\x8d\x11\n" + | |
| 1421 | 1438 | "\x0eServerEnvelope\x120\n" + | |
| 1422 | 1439 | "\awelcome\x18\x01 \x01(\v2\x14.vev.wire.v1.WelcomeH\x00R\awelcome\x12-\n" + | |
| @@ -1502,34 +1519,35 @@ var file_envelope_proto_goTypes = []any{ | |||
| 1502 | 1519 | (*SelectTab)(nil), // 32: vev.wire.v1.SelectTab | |
| 1503 | 1520 | (*RemotePreviewWatch)(nil), // 33: vev.wire.v1.RemotePreviewWatch | |
| 1504 | 1521 | (*TerminalFocus)(nil), // 34: vev.wire.v1.TerminalFocus | |
| 1505 | - | (*Welcome)(nil), // 35: vev.wire.v1.Welcome | |
| 1506 | - | (*ErrorMsg)(nil), // 36: vev.wire.v1.ErrorMsg | |
| 1507 | - | (*Output)(nil), // 37: vev.wire.v1.Output | |
| 1508 | - | (*Detached)(nil), // 38: vev.wire.v1.Detached | |
| 1509 | - | (*Pong)(nil), // 39: vev.wire.v1.Pong | |
| 1510 | - | (*Sessions)(nil), // 40: vev.wire.v1.Sessions | |
| 1511 | - | (*CommandResult)(nil), // 41: vev.wire.v1.CommandResult | |
| 1512 | - | (*AttachTarget)(nil), // 42: vev.wire.v1.AttachTarget | |
| 1513 | - | (*RemotePreview)(nil), // 43: vev.wire.v1.RemotePreview | |
| 1514 | - | (*CommittedRouteIdentity)(nil), // 44: vev.wire.v1.CommittedRouteIdentity | |
| 1515 | - | (*RouteNavigationAction)(nil), // 45: vev.wire.v1.RouteNavigationAction | |
| 1516 | - | (*RouteCreateSessionAction)(nil), // 46: vev.wire.v1.RouteCreateSessionAction | |
| 1517 | - | (*RoutePosition)(nil), // 47: vev.wire.v1.RoutePosition | |
| 1518 | - | (*RouteRetired)(nil), // 48: vev.wire.v1.RouteRetired | |
| 1519 | - | (*SamePeerSwitchFailure)(nil), // 49: vev.wire.v1.SamePeerSwitchFailure | |
| 1520 | - | (*UIReceipt)(nil), // 50: vev.wire.v1.UIReceipt | |
| 1521 | - | (*UIViewUpdate)(nil), // 51: vev.wire.v1.UIViewUpdate | |
| 1522 | - | (*NavigationInventoryResponse)(nil), // 52: vev.wire.v1.NavigationInventoryResponse | |
| 1523 | - | (*NavigationInventoryDemand)(nil), // 53: vev.wire.v1.NavigationInventoryDemand | |
| 1524 | - | (*NavigationInventorySelection)(nil), // 54: vev.wire.v1.NavigationInventorySelection | |
| 1525 | - | (*PickerOffer)(nil), // 55: vev.wire.v1.PickerOffer | |
| 1526 | - | (*PickerSnapshot)(nil), // 56: vev.wire.v1.PickerSnapshot | |
| 1527 | - | (*PickerClosed)(nil), // 57: vev.wire.v1.PickerClosed | |
| 1528 | - | (*PickerResult)(nil), // 58: vev.wire.v1.PickerResult | |
| 1529 | - | (*PickerFailure)(nil), // 59: vev.wire.v1.PickerFailure | |
| 1530 | - | (*AttachmentSuspended)(nil), // 60: vev.wire.v1.AttachmentSuspended | |
| 1531 | - | (*AttachmentActivated)(nil), // 61: vev.wire.v1.AttachmentActivated | |
| 1532 | - | (*KillResult)(nil), // 62: vev.wire.v1.KillResult | |
| 1522 | + | (*ModifierState)(nil), // 35: vev.wire.v1.ModifierState | |
| 1523 | + | (*Welcome)(nil), // 36: vev.wire.v1.Welcome | |
| 1524 | + | (*ErrorMsg)(nil), // 37: vev.wire.v1.ErrorMsg | |
| 1525 | + | (*Output)(nil), // 38: vev.wire.v1.Output | |
| 1526 | + | (*Detached)(nil), // 39: vev.wire.v1.Detached | |
| 1527 | + | (*Pong)(nil), // 40: vev.wire.v1.Pong | |
| 1528 | + | (*Sessions)(nil), // 41: vev.wire.v1.Sessions | |
| 1529 | + | (*CommandResult)(nil), // 42: vev.wire.v1.CommandResult | |
| 1530 | + | (*AttachTarget)(nil), // 43: vev.wire.v1.AttachTarget | |
| 1531 | + | (*RemotePreview)(nil), // 44: vev.wire.v1.RemotePreview | |
| 1532 | + | (*CommittedRouteIdentity)(nil), // 45: vev.wire.v1.CommittedRouteIdentity | |
| 1533 | + | (*RouteNavigationAction)(nil), // 46: vev.wire.v1.RouteNavigationAction | |
| 1534 | + | (*RouteCreateSessionAction)(nil), // 47: vev.wire.v1.RouteCreateSessionAction | |
| 1535 | + | (*RoutePosition)(nil), // 48: vev.wire.v1.RoutePosition | |
| 1536 | + | (*RouteRetired)(nil), // 49: vev.wire.v1.RouteRetired | |
| 1537 | + | (*SamePeerSwitchFailure)(nil), // 50: vev.wire.v1.SamePeerSwitchFailure | |
| 1538 | + | (*UIReceipt)(nil), // 51: vev.wire.v1.UIReceipt | |
| 1539 | + | (*UIViewUpdate)(nil), // 52: vev.wire.v1.UIViewUpdate | |
| 1540 | + | (*NavigationInventoryResponse)(nil), // 53: vev.wire.v1.NavigationInventoryResponse | |
| 1541 | + | (*NavigationInventoryDemand)(nil), // 54: vev.wire.v1.NavigationInventoryDemand | |
| 1542 | + | (*NavigationInventorySelection)(nil), // 55: vev.wire.v1.NavigationInventorySelection | |
| 1543 | + | (*PickerOffer)(nil), // 56: vev.wire.v1.PickerOffer | |
| 1544 | + | (*PickerSnapshot)(nil), // 57: vev.wire.v1.PickerSnapshot | |
| 1545 | + | (*PickerClosed)(nil), // 58: vev.wire.v1.PickerClosed | |
| 1546 | + | (*PickerResult)(nil), // 59: vev.wire.v1.PickerResult | |
| 1547 | + | (*PickerFailure)(nil), // 60: vev.wire.v1.PickerFailure | |
| 1548 | + | (*AttachmentSuspended)(nil), // 61: vev.wire.v1.AttachmentSuspended | |
| 1549 | + | (*AttachmentActivated)(nil), // 62: vev.wire.v1.AttachmentActivated | |
| 1550 | + | (*KillResult)(nil), // 63: vev.wire.v1.KillResult | |
| 1533 | 1551 | } | |
| 1534 | 1552 | var file_envelope_proto_depIdxs = []int32{ | |
| 1535 | 1553 | 0, // 0: vev.wire.v1.PreambleRequest.role:type_name -> vev.wire.v1.PreambleRole | |
| @@ -1564,40 +1582,41 @@ var file_envelope_proto_depIdxs = []int32{ | |||
| 1564 | 1582 | 32, // 29: vev.wire.v1.ClientEnvelope.select_tab:type_name -> vev.wire.v1.SelectTab | |
| 1565 | 1583 | 33, // 30: vev.wire.v1.ClientEnvelope.remote_preview_watch:type_name -> vev.wire.v1.RemotePreviewWatch | |
| 1566 | 1584 | 34, // 31: vev.wire.v1.ClientEnvelope.terminal_focus:type_name -> vev.wire.v1.TerminalFocus | |
| 1567 | - | 35, // 32: vev.wire.v1.ServerEnvelope.welcome:type_name -> vev.wire.v1.Welcome | |
| 1568 | - | 36, // 33: vev.wire.v1.ServerEnvelope.error:type_name -> vev.wire.v1.ErrorMsg | |
| 1569 | - | 37, // 34: vev.wire.v1.ServerEnvelope.output:type_name -> vev.wire.v1.Output | |
| 1570 | - | 38, // 35: vev.wire.v1.ServerEnvelope.detached:type_name -> vev.wire.v1.Detached | |
| 1571 | - | 39, // 36: vev.wire.v1.ServerEnvelope.pong:type_name -> vev.wire.v1.Pong | |
| 1572 | - | 40, // 37: vev.wire.v1.ServerEnvelope.sessions:type_name -> vev.wire.v1.Sessions | |
| 1573 | - | 41, // 38: vev.wire.v1.ServerEnvelope.command_result:type_name -> vev.wire.v1.CommandResult | |
| 1574 | - | 42, // 39: vev.wire.v1.ServerEnvelope.attach_target:type_name -> vev.wire.v1.AttachTarget | |
| 1575 | - | 43, // 40: vev.wire.v1.ServerEnvelope.remote_preview:type_name -> vev.wire.v1.RemotePreview | |
| 1576 | - | 44, // 41: vev.wire.v1.ServerEnvelope.committed_route_identity:type_name -> vev.wire.v1.CommittedRouteIdentity | |
| 1577 | - | 45, // 42: vev.wire.v1.ServerEnvelope.route_navigation_action:type_name -> vev.wire.v1.RouteNavigationAction | |
| 1578 | - | 46, // 43: vev.wire.v1.ServerEnvelope.route_create_session_action:type_name -> vev.wire.v1.RouteCreateSessionAction | |
| 1579 | - | 22, // 44: vev.wire.v1.ServerEnvelope.route_navigation_failure:type_name -> vev.wire.v1.RouteNavigationFailure | |
| 1580 | - | 47, // 45: vev.wire.v1.ServerEnvelope.route_position:type_name -> vev.wire.v1.RoutePosition | |
| 1581 | - | 48, // 46: vev.wire.v1.ServerEnvelope.route_retired:type_name -> vev.wire.v1.RouteRetired | |
| 1582 | - | 49, // 47: vev.wire.v1.ServerEnvelope.same_peer_switch_failure:type_name -> vev.wire.v1.SamePeerSwitchFailure | |
| 1583 | - | 50, // 48: vev.wire.v1.ServerEnvelope.ui_receipt:type_name -> vev.wire.v1.UIReceipt | |
| 1584 | - | 51, // 49: vev.wire.v1.ServerEnvelope.ui_view_update:type_name -> vev.wire.v1.UIViewUpdate | |
| 1585 | - | 52, // 50: vev.wire.v1.ServerEnvelope.navigation_inventory_response:type_name -> vev.wire.v1.NavigationInventoryResponse | |
| 1586 | - | 53, // 51: vev.wire.v1.ServerEnvelope.navigation_inventory_demand:type_name -> vev.wire.v1.NavigationInventoryDemand | |
| 1587 | - | 54, // 52: vev.wire.v1.ServerEnvelope.navigation_inventory_selection:type_name -> vev.wire.v1.NavigationInventorySelection | |
| 1588 | - | 55, // 53: vev.wire.v1.ServerEnvelope.picker_offer:type_name -> vev.wire.v1.PickerOffer | |
| 1589 | - | 56, // 54: vev.wire.v1.ServerEnvelope.picker_snapshot:type_name -> vev.wire.v1.PickerSnapshot | |
| 1590 | - | 57, // 55: vev.wire.v1.ServerEnvelope.picker_closed:type_name -> vev.wire.v1.PickerClosed | |
| 1591 | - | 58, // 56: vev.wire.v1.ServerEnvelope.picker_result:type_name -> vev.wire.v1.PickerResult | |
| 1592 | - | 59, // 57: vev.wire.v1.ServerEnvelope.picker_failure:type_name -> vev.wire.v1.PickerFailure | |
| 1593 | - | 60, // 58: vev.wire.v1.ServerEnvelope.attachment_suspended:type_name -> vev.wire.v1.AttachmentSuspended | |
| 1594 | - | 61, // 59: vev.wire.v1.ServerEnvelope.attachment_activated:type_name -> vev.wire.v1.AttachmentActivated | |
| 1595 | - | 62, // 60: vev.wire.v1.ServerEnvelope.kill_result:type_name -> vev.wire.v1.KillResult | |
| 1596 | - | 61, // [61:61] is the sub-list for method output_type | |
| 1597 | - | 61, // [61:61] is the sub-list for method input_type | |
| 1598 | - | 61, // [61:61] is the sub-list for extension type_name | |
| 1599 | - | 61, // [61:61] is the sub-list for extension extendee | |
| 1600 | - | 0, // [0:61] is the sub-list for field type_name | |
| 1585 | + | 35, // 32: vev.wire.v1.ClientEnvelope.modifier_state:type_name -> vev.wire.v1.ModifierState | |
| 1586 | + | 36, // 33: vev.wire.v1.ServerEnvelope.welcome:type_name -> vev.wire.v1.Welcome | |
| 1587 | + | 37, // 34: vev.wire.v1.ServerEnvelope.error:type_name -> vev.wire.v1.ErrorMsg | |
| 1588 | + | 38, // 35: vev.wire.v1.ServerEnvelope.output:type_name -> vev.wire.v1.Output | |
| 1589 | + | 39, // 36: vev.wire.v1.ServerEnvelope.detached:type_name -> vev.wire.v1.Detached | |
| 1590 | + | 40, // 37: vev.wire.v1.ServerEnvelope.pong:type_name -> vev.wire.v1.Pong | |
| 1591 | + | 41, // 38: vev.wire.v1.ServerEnvelope.sessions:type_name -> vev.wire.v1.Sessions | |
| 1592 | + | 42, // 39: vev.wire.v1.ServerEnvelope.command_result:type_name -> vev.wire.v1.CommandResult | |
| 1593 | + | 43, // 40: vev.wire.v1.ServerEnvelope.attach_target:type_name -> vev.wire.v1.AttachTarget | |
| 1594 | + | 44, // 41: vev.wire.v1.ServerEnvelope.remote_preview:type_name -> vev.wire.v1.RemotePreview | |
| 1595 | + | 45, // 42: vev.wire.v1.ServerEnvelope.committed_route_identity:type_name -> vev.wire.v1.CommittedRouteIdentity | |
| 1596 | + | 46, // 43: vev.wire.v1.ServerEnvelope.route_navigation_action:type_name -> vev.wire.v1.RouteNavigationAction | |
| 1597 | + | 47, // 44: vev.wire.v1.ServerEnvelope.route_create_session_action:type_name -> vev.wire.v1.RouteCreateSessionAction | |
| 1598 | + | 22, // 45: vev.wire.v1.ServerEnvelope.route_navigation_failure:type_name -> vev.wire.v1.RouteNavigationFailure | |
| 1599 | + | 48, // 46: vev.wire.v1.ServerEnvelope.route_position:type_name -> vev.wire.v1.RoutePosition | |
| 1600 | + | 49, // 47: vev.wire.v1.ServerEnvelope.route_retired:type_name -> vev.wire.v1.RouteRetired | |
| 1601 | + | 50, // 48: vev.wire.v1.ServerEnvelope.same_peer_switch_failure:type_name -> vev.wire.v1.SamePeerSwitchFailure | |
| 1602 | + | 51, // 49: vev.wire.v1.ServerEnvelope.ui_receipt:type_name -> vev.wire.v1.UIReceipt | |
| 1603 | + | 52, // 50: vev.wire.v1.ServerEnvelope.ui_view_update:type_name -> vev.wire.v1.UIViewUpdate | |
| 1604 | + | 53, // 51: vev.wire.v1.ServerEnvelope.navigation_inventory_response:type_name -> vev.wire.v1.NavigationInventoryResponse | |
| 1605 | + | 54, // 52: vev.wire.v1.ServerEnvelope.navigation_inventory_demand:type_name -> vev.wire.v1.NavigationInventoryDemand | |
| 1606 | + | 55, // 53: vev.wire.v1.ServerEnvelope.navigation_inventory_selection:type_name -> vev.wire.v1.NavigationInventorySelection | |
| 1607 | + | 56, // 54: vev.wire.v1.ServerEnvelope.picker_offer:type_name -> vev.wire.v1.PickerOffer | |
| 1608 | + | 57, // 55: vev.wire.v1.ServerEnvelope.picker_snapshot:type_name -> vev.wire.v1.PickerSnapshot | |
| 1609 | + | 58, // 56: vev.wire.v1.ServerEnvelope.picker_closed:type_name -> vev.wire.v1.PickerClosed | |
| 1610 | + | 59, // 57: vev.wire.v1.ServerEnvelope.picker_result:type_name -> vev.wire.v1.PickerResult | |
| 1611 | + | 60, // 58: vev.wire.v1.ServerEnvelope.picker_failure:type_name -> vev.wire.v1.PickerFailure | |
| 1612 | + | 61, // 59: vev.wire.v1.ServerEnvelope.attachment_suspended:type_name -> vev.wire.v1.AttachmentSuspended | |
| 1613 | + | 62, // 60: vev.wire.v1.ServerEnvelope.attachment_activated:type_name -> vev.wire.v1.AttachmentActivated | |
| 1614 | + | 63, // 61: vev.wire.v1.ServerEnvelope.kill_result:type_name -> vev.wire.v1.KillResult | |
| 1615 | + | 62, // [62:62] is the sub-list for method output_type | |
| 1616 | + | 62, // [62:62] is the sub-list for method input_type | |
| 1617 | + | 62, // [62:62] is the sub-list for extension type_name | |
| 1618 | + | 62, // [62:62] is the sub-list for extension extendee | |
| 1619 | + | 0, // [0:62] is the sub-list for field type_name | |
| 1601 | 1620 | } | |
| 1602 | 1621 | ||
| 1603 | 1622 | func init() { file_envelope_proto_init() } | |
| @@ -1640,6 +1659,7 @@ func file_envelope_proto_init() { | |||
| 1640 | 1659 | (*ClientEnvelope_SelectTab)(nil), | |
| 1641 | 1660 | (*ClientEnvelope_RemotePreviewWatch)(nil), | |
| 1642 | 1661 | (*ClientEnvelope_TerminalFocus)(nil), | |
| 1662 | + | (*ClientEnvelope_ModifierState)(nil), | |
| 1643 | 1663 | } | |
| 1644 | 1664 | file_envelope_proto_msgTypes[5].OneofWrappers = []any{ | |
| 1645 | 1665 | (*ServerEnvelope_Welcome)(nil), | |
| @@ -360,6 +360,7 @@ type ClientEnvelope struct { | |||||
| 360 | // *ClientEnvelope_SelectTab | 360 | // *ClientEnvelope_SelectTab | ||
| 361 | // *ClientEnvelope_RemotePreviewWatch | 361 | // *ClientEnvelope_RemotePreviewWatch | ||
| 362 | // *ClientEnvelope_TerminalFocus | 362 | // *ClientEnvelope_TerminalFocus | ||
| 363 | + | // *ClientEnvelope_ModifierState | |||
| 363 | Payload isClientEnvelope_Payload `protobuf_oneof:"payload"` | 364 | Payload isClientEnvelope_Payload `protobuf_oneof:"payload"` | ||
| 364 | unknownFields protoimpl.UnknownFields | 365 | unknownFields protoimpl.UnknownFields | ||
| 365 | sizeCache protoimpl.SizeCache | 366 | sizeCache protoimpl.SizeCache | ||
| @@ -663,6 +664,15 @@ func (x *ClientEnvelope) GetTerminalFocus() *TerminalFocus { | |||||
| 663 | return nil | 664 | return nil | ||
| 664 | } | 665 | } | ||
| 665 | 666 | ||||
| 667 | + | func (x *ClientEnvelope) GetModifierState() *ModifierState { | |||
| 668 | + | if x != nil { | |||
| 669 | + | if x, ok := x.Payload.(*ClientEnvelope_ModifierState); ok { | |||
| 670 | + | return x.ModifierState | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | return nil | |||
| 674 | + | } | |||
| 675 | + | ||||
| 666 | type isClientEnvelope_Payload interface { | 676 | type isClientEnvelope_Payload interface { | ||
| 667 | isClientEnvelope_Payload() | 677 | isClientEnvelope_Payload() | ||
| 668 | } | 678 | } | ||
| @@ -783,6 +793,10 @@ type ClientEnvelope_TerminalFocus struct { | |||||
| 783 | TerminalFocus *TerminalFocus `protobuf:"bytes,33,opt,name=terminal_focus,json=terminalFocus,proto3,oneof"` | 793 | TerminalFocus *TerminalFocus `protobuf:"bytes,33,opt,name=terminal_focus,json=terminalFocus,proto3,oneof"` | ||
| 784 | } | 794 | } | ||
| 785 | 795 | ||||
| 796 | + | type ClientEnvelope_ModifierState struct { | |||
| 797 | + | ModifierState *ModifierState `protobuf:"bytes,34,opt,name=modifier_state,json=modifierState,proto3,oneof"` | |||
| 798 | + | } | |||
| 799 | + | ||||
| 786 | func (*ClientEnvelope_Hello) isClientEnvelope_Payload() {} | 800 | func (*ClientEnvelope_Hello) isClientEnvelope_Payload() {} | ||
| 787 | 801 | ||||
| 788 | func (*ClientEnvelope_Input) isClientEnvelope_Payload() {} | 802 | func (*ClientEnvelope_Input) isClientEnvelope_Payload() {} | ||
| @@ -841,6 +855,8 @@ func (*ClientEnvelope_RemotePreviewWatch) isClientEnvelope_Payload() {} | |||||
| 841 | 855 | ||||
| 842 | func (*ClientEnvelope_TerminalFocus) isClientEnvelope_Payload() {} | 856 | func (*ClientEnvelope_TerminalFocus) isClientEnvelope_Payload() {} | ||
| 843 | 857 | ||||
| 858 | + | func (*ClientEnvelope_ModifierState) isClientEnvelope_Payload() {} | |||
| 859 | + | ||||
| 844 | // ServerEnvelope is the closed server-to-client union. Exactly one variant | 860 | // ServerEnvelope is the closed server-to-client union. Exactly one variant | ||
| 845 | // must be set per envelope. | 861 | // must be set per envelope. | ||
| 846 | type ServerEnvelope struct { | 862 | type ServerEnvelope struct { | ||
| @@ -1383,7 +1399,7 @@ const file_envelope_proto_rawDesc = "" + | |||||
| 1383 | "\x11output_data_limit\x18\x06 \x01(\x04R\x0foutputDataLimit\x12'\n" + | 1399 | "\x11output_data_limit\x18\x06 \x01(\x04R\x0foutputDataLimit\x12'\n" + | ||
| 1384 | "\x0fcapability_bits\x18\a \x01(\rR\x0ecapabilityBits\x12\x1a\n" + | 1400 | "\x0fcapability_bits\x18\a \x01(\rR\x0ecapabilityBits\x12\x1a\n" + | ||
| 1385 | "\baccepted\x18\b \x01(\bR\baccepted\x12@\n" + | 1401 | "\baccepted\x18\b \x01(\bR\baccepted\x12@\n" + | ||
| 1386 | - | "\trejection\x18\t \x01(\v2\".vev.wire.v1.PreambleRejectionCodeR\trejection\"\xb0\x10\n" + | 1402 | + | "\trejection\x18\t \x01(\v2\".vev.wire.v1.PreambleRejectionCodeR\trejection\"\xf5\x10\n" + |
| 1387 | "\x0eClientEnvelope\x12*\n" + | 1403 | "\x0eClientEnvelope\x12*\n" + | ||
| 1388 | "\x05hello\x18\x01 \x01(\v2\x12.vev.wire.v1.HelloH\x00R\x05hello\x12*\n" + | 1404 | "\x05hello\x18\x01 \x01(\v2\x12.vev.wire.v1.HelloH\x00R\x05hello\x12*\n" + | ||
| 1389 | "\x05input\x18\x02 \x01(\v2\x12.vev.wire.v1.InputH\x00R\x05input\x12-\n" + | 1405 | "\x05input\x18\x02 \x01(\v2\x12.vev.wire.v1.InputH\x00R\x05input\x12-\n" + | ||
| @@ -1416,7 +1432,8 @@ const file_envelope_proto_rawDesc = "" + | |||||
| 1416 | "\n" + | 1432 | "\n" + | ||
| 1417 | "select_tab\x18\x1f \x01(\v2\x16.vev.wire.v1.SelectTabH\x00R\tselectTab\x12S\n" + | 1433 | "select_tab\x18\x1f \x01(\v2\x16.vev.wire.v1.SelectTabH\x00R\tselectTab\x12S\n" + | ||
| 1418 | "\x14remote_preview_watch\x18 \x01(\v2\x1f.vev.wire.v1.RemotePreviewWatchH\x00R\x12remotePreviewWatch\x12C\n" + | 1434 | "\x14remote_preview_watch\x18 \x01(\v2\x1f.vev.wire.v1.RemotePreviewWatchH\x00R\x12remotePreviewWatch\x12C\n" + | ||
| 1419 | - | "\x0eterminal_focus\x18! \x01(\v2\x1a.vev.wire.v1.TerminalFocusH\x00R\rterminalFocusB\t\n" + | 1435 | + | "\x0eterminal_focus\x18! \x01(\v2\x1a.vev.wire.v1.TerminalFocusH\x00R\rterminalFocus\x12C\n" + |
| 1436 | + | "\x0emodifier_state\x18\" \x01(\v2\x1a.vev.wire.v1.ModifierStateH\x00R\rmodifierStateB\t\n" + | |||
| 1420 | "\apayloadJ\x04\b\x0e\x10\x0fJ\x04\b\x1b\x10\x1c\"\x8d\x11\n" + | 1437 | "\apayloadJ\x04\b\x0e\x10\x0fJ\x04\b\x1b\x10\x1c\"\x8d\x11\n" + | ||
| 1421 | "\x0eServerEnvelope\x120\n" + | 1438 | "\x0eServerEnvelope\x120\n" + | ||
| 1422 | "\awelcome\x18\x01 \x01(\v2\x14.vev.wire.v1.WelcomeH\x00R\awelcome\x12-\n" + | 1439 | "\awelcome\x18\x01 \x01(\v2\x14.vev.wire.v1.WelcomeH\x00R\awelcome\x12-\n" + | ||
| @@ -1502,34 +1519,35 @@ var file_envelope_proto_goTypes = []any{ | |||||
| 1502 | (*SelectTab)(nil), // 32: vev.wire.v1.SelectTab | 1519 | (*SelectTab)(nil), // 32: vev.wire.v1.SelectTab | ||
| 1503 | (*RemotePreviewWatch)(nil), // 33: vev.wire.v1.RemotePreviewWatch | 1520 | (*RemotePreviewWatch)(nil), // 33: vev.wire.v1.RemotePreviewWatch | ||
| 1504 | (*TerminalFocus)(nil), // 34: vev.wire.v1.TerminalFocus | 1521 | (*TerminalFocus)(nil), // 34: vev.wire.v1.TerminalFocus | ||
| 1505 | - | (*Welcome)(nil), // 35: vev.wire.v1.Welcome | 1522 | + | (*ModifierState)(nil), // 35: vev.wire.v1.ModifierState |
| 1506 | - | (*ErrorMsg)(nil), // 36: vev.wire.v1.ErrorMsg | 1523 | + | (*Welcome)(nil), // 36: vev.wire.v1.Welcome |
| 1507 | - | (*Output)(nil), // 37: vev.wire.v1.Output | 1524 | + | (*ErrorMsg)(nil), // 37: vev.wire.v1.ErrorMsg |
| 1508 | - | (*Detached)(nil), // 38: vev.wire.v1.Detached | 1525 | + | (*Output)(nil), // 38: vev.wire.v1.Output |
| 1509 | - | (*Pong)(nil), // 39: vev.wire.v1.Pong | 1526 | + | (*Detached)(nil), // 39: vev.wire.v1.Detached |
| 1510 | - | (*Sessions)(nil), // 40: vev.wire.v1.Sessions | 1527 | + | (*Pong)(nil), // 40: vev.wire.v1.Pong |
| 1511 | - | (*CommandResult)(nil), // 41: vev.wire.v1.CommandResult | 1528 | + | (*Sessions)(nil), // 41: vev.wire.v1.Sessions |
| 1512 | - | (*AttachTarget)(nil), // 42: vev.wire.v1.AttachTarget | 1529 | + | (*CommandResult)(nil), // 42: vev.wire.v1.CommandResult |
| 1513 | - | (*RemotePreview)(nil), // 43: vev.wire.v1.RemotePreview | 1530 | + | (*AttachTarget)(nil), // 43: vev.wire.v1.AttachTarget |
| 1514 | - | (*CommittedRouteIdentity)(nil), // 44: vev.wire.v1.CommittedRouteIdentity | 1531 | + | (*RemotePreview)(nil), // 44: vev.wire.v1.RemotePreview |
| 1515 | - | (*RouteNavigationAction)(nil), // 45: vev.wire.v1.RouteNavigationAction | 1532 | + | (*CommittedRouteIdentity)(nil), // 45: vev.wire.v1.CommittedRouteIdentity |
| 1516 | - | (*RouteCreateSessionAction)(nil), // 46: vev.wire.v1.RouteCreateSessionAction | 1533 | + | (*RouteNavigationAction)(nil), // 46: vev.wire.v1.RouteNavigationAction |
| 1517 | - | (*RoutePosition)(nil), // 47: vev.wire.v1.RoutePosition | 1534 | + | (*RouteCreateSessionAction)(nil), // 47: vev.wire.v1.RouteCreateSessionAction |
| 1518 | - | (*RouteRetired)(nil), // 48: vev.wire.v1.RouteRetired | 1535 | + | (*RoutePosition)(nil), // 48: vev.wire.v1.RoutePosition |
| 1519 | - | (*SamePeerSwitchFailure)(nil), // 49: vev.wire.v1.SamePeerSwitchFailure | 1536 | + | (*RouteRetired)(nil), // 49: vev.wire.v1.RouteRetired |
| 1520 | - | (*UIReceipt)(nil), // 50: vev.wire.v1.UIReceipt | 1537 | + | (*SamePeerSwitchFailure)(nil), // 50: vev.wire.v1.SamePeerSwitchFailure |
| 1521 | - | (*UIViewUpdate)(nil), // 51: vev.wire.v1.UIViewUpdate | 1538 | + | (*UIReceipt)(nil), // 51: vev.wire.v1.UIReceipt |
| 1522 | - | (*NavigationInventoryResponse)(nil), // 52: vev.wire.v1.NavigationInventoryResponse | 1539 | + | (*UIViewUpdate)(nil), // 52: vev.wire.v1.UIViewUpdate |
| 1523 | - | (*NavigationInventoryDemand)(nil), // 53: vev.wire.v1.NavigationInventoryDemand | 1540 | + | (*NavigationInventoryResponse)(nil), // 53: vev.wire.v1.NavigationInventoryResponse |
| 1524 | - | (*NavigationInventorySelection)(nil), // 54: vev.wire.v1.NavigationInventorySelection | 1541 | + | (*NavigationInventoryDemand)(nil), // 54: vev.wire.v1.NavigationInventoryDemand |
| 1525 | - | (*PickerOffer)(nil), // 55: vev.wire.v1.PickerOffer | 1542 | + | (*NavigationInventorySelection)(nil), // 55: vev.wire.v1.NavigationInventorySelection |
| 1526 | - | (*PickerSnapshot)(nil), // 56: vev.wire.v1.PickerSnapshot | 1543 | + | (*PickerOffer)(nil), // 56: vev.wire.v1.PickerOffer |
| 1527 | - | (*PickerClosed)(nil), // 57: vev.wire.v1.PickerClosed | 1544 | + | (*PickerSnapshot)(nil), // 57: vev.wire.v1.PickerSnapshot |
| 1528 | - | (*PickerResult)(nil), // 58: vev.wire.v1.PickerResult | 1545 | + | (*PickerClosed)(nil), // 58: vev.wire.v1.PickerClosed |
| 1529 | - | (*PickerFailure)(nil), // 59: vev.wire.v1.PickerFailure | 1546 | + | (*PickerResult)(nil), // 59: vev.wire.v1.PickerResult |
| 1530 | - | (*AttachmentSuspended)(nil), // 60: vev.wire.v1.AttachmentSuspended | 1547 | + | (*PickerFailure)(nil), // 60: vev.wire.v1.PickerFailure |
| 1531 | - | (*AttachmentActivated)(nil), // 61: vev.wire.v1.AttachmentActivated | 1548 | + | (*AttachmentSuspended)(nil), // 61: vev.wire.v1.AttachmentSuspended |
| 1532 | - | (*KillResult)(nil), // 62: vev.wire.v1.KillResult | 1549 | + | (*AttachmentActivated)(nil), // 62: vev.wire.v1.AttachmentActivated |
| 1550 | + | (*KillResult)(nil), // 63: vev.wire.v1.KillResult | |||
| 1533 | } | 1551 | } | ||
| 1534 | var file_envelope_proto_depIdxs = []int32{ | 1552 | var file_envelope_proto_depIdxs = []int32{ | ||
| 1535 | 0, // 0: vev.wire.v1.PreambleRequest.role:type_name -> vev.wire.v1.PreambleRole | 1553 | 0, // 0: vev.wire.v1.PreambleRequest.role:type_name -> vev.wire.v1.PreambleRole | ||
| @@ -1564,40 +1582,41 @@ var file_envelope_proto_depIdxs = []int32{ | |||||
| 1564 | 32, // 29: vev.wire.v1.ClientEnvelope.select_tab:type_name -> vev.wire.v1.SelectTab | 1582 | 32, // 29: vev.wire.v1.ClientEnvelope.select_tab:type_name -> vev.wire.v1.SelectTab | ||
| 1565 | 33, // 30: vev.wire.v1.ClientEnvelope.remote_preview_watch:type_name -> vev.wire.v1.RemotePreviewWatch | 1583 | 33, // 30: vev.wire.v1.ClientEnvelope.remote_preview_watch:type_name -> vev.wire.v1.RemotePreviewWatch | ||
| 1566 | 34, // 31: vev.wire.v1.ClientEnvelope.terminal_focus:type_name -> vev.wire.v1.TerminalFocus | 1584 | 34, // 31: vev.wire.v1.ClientEnvelope.terminal_focus:type_name -> vev.wire.v1.TerminalFocus | ||
| 1567 | - | 35, // 32: vev.wire.v1.ServerEnvelope.welcome:type_name -> vev.wire.v1.Welcome | 1585 | + | 35, // 32: vev.wire.v1.ClientEnvelope.modifier_state:type_name -> vev.wire.v1.ModifierState |
| 1568 | - | 36, // 33: vev.wire.v1.ServerEnvelope.error:type_name -> vev.wire.v1.ErrorMsg | 1586 | + | 36, // 33: vev.wire.v1.ServerEnvelope.welcome:type_name -> vev.wire.v1.Welcome |
| 1569 | - | 37, // 34: vev.wire.v1.ServerEnvelope.output:type_name -> vev.wire.v1.Output | 1587 | + | 37, // 34: vev.wire.v1.ServerEnvelope.error:type_name -> vev.wire.v1.ErrorMsg |
| 1570 | - | 38, // 35: vev.wire.v1.ServerEnvelope.detached:type_name -> vev.wire.v1.Detached | 1588 | + | 38, // 35: vev.wire.v1.ServerEnvelope.output:type_name -> vev.wire.v1.Output |
| 1571 | - | 39, // 36: vev.wire.v1.ServerEnvelope.pong:type_name -> vev.wire.v1.Pong | 1589 | + | 39, // 36: vev.wire.v1.ServerEnvelope.detached:type_name -> vev.wire.v1.Detached |
| 1572 | - | 40, // 37: vev.wire.v1.ServerEnvelope.sessions:type_name -> vev.wire.v1.Sessions | 1590 | + | 40, // 37: vev.wire.v1.ServerEnvelope.pong:type_name -> vev.wire.v1.Pong |
| 1573 | - | 41, // 38: vev.wire.v1.ServerEnvelope.command_result:type_name -> vev.wire.v1.CommandResult | 1591 | + | 41, // 38: vev.wire.v1.ServerEnvelope.sessions:type_name -> vev.wire.v1.Sessions |
| 1574 | - | 42, // 39: vev.wire.v1.ServerEnvelope.attach_target:type_name -> vev.wire.v1.AttachTarget | 1592 | + | 42, // 39: vev.wire.v1.ServerEnvelope.command_result:type_name -> vev.wire.v1.CommandResult |
| 1575 | - | 43, // 40: vev.wire.v1.ServerEnvelope.remote_preview:type_name -> vev.wire.v1.RemotePreview | 1593 | + | 43, // 40: vev.wire.v1.ServerEnvelope.attach_target:type_name -> vev.wire.v1.AttachTarget |
| 1576 | - | 44, // 41: vev.wire.v1.ServerEnvelope.committed_route_identity:type_name -> vev.wire.v1.CommittedRouteIdentity | 1594 | + | 44, // 41: vev.wire.v1.ServerEnvelope.remote_preview:type_name -> vev.wire.v1.RemotePreview |
| 1577 | - | 45, // 42: vev.wire.v1.ServerEnvelope.route_navigation_action:type_name -> vev.wire.v1.RouteNavigationAction | 1595 | + | 45, // 42: vev.wire.v1.ServerEnvelope.committed_route_identity:type_name -> vev.wire.v1.CommittedRouteIdentity |
| 1578 | - | 46, // 43: vev.wire.v1.ServerEnvelope.route_create_session_action:type_name -> vev.wire.v1.RouteCreateSessionAction | 1596 | + | 46, // 43: vev.wire.v1.ServerEnvelope.route_navigation_action:type_name -> vev.wire.v1.RouteNavigationAction |
| 1579 | - | 22, // 44: vev.wire.v1.ServerEnvelope.route_navigation_failure:type_name -> vev.wire.v1.RouteNavigationFailure | 1597 | + | 47, // 44: vev.wire.v1.ServerEnvelope.route_create_session_action:type_name -> vev.wire.v1.RouteCreateSessionAction |
| 1580 | - | 47, // 45: vev.wire.v1.ServerEnvelope.route_position:type_name -> vev.wire.v1.RoutePosition | 1598 | + | 22, // 45: vev.wire.v1.ServerEnvelope.route_navigation_failure:type_name -> vev.wire.v1.RouteNavigationFailure |
| 1581 | - | 48, // 46: vev.wire.v1.ServerEnvelope.route_retired:type_name -> vev.wire.v1.RouteRetired | 1599 | + | 48, // 46: vev.wire.v1.ServerEnvelope.route_position:type_name -> vev.wire.v1.RoutePosition |
| 1582 | - | 49, // 47: vev.wire.v1.ServerEnvelope.same_peer_switch_failure:type_name -> vev.wire.v1.SamePeerSwitchFailure | 1600 | + | 49, // 47: vev.wire.v1.ServerEnvelope.route_retired:type_name -> vev.wire.v1.RouteRetired |
| 1583 | - | 50, // 48: vev.wire.v1.ServerEnvelope.ui_receipt:type_name -> vev.wire.v1.UIReceipt | 1601 | + | 50, // 48: vev.wire.v1.ServerEnvelope.same_peer_switch_failure:type_name -> vev.wire.v1.SamePeerSwitchFailure |
| 1584 | - | 51, // 49: vev.wire.v1.ServerEnvelope.ui_view_update:type_name -> vev.wire.v1.UIViewUpdate | 1602 | + | 51, // 49: vev.wire.v1.ServerEnvelope.ui_receipt:type_name -> vev.wire.v1.UIReceipt |
| 1585 | - | 52, // 50: vev.wire.v1.ServerEnvelope.navigation_inventory_response:type_name -> vev.wire.v1.NavigationInventoryResponse | 1603 | + | 52, // 50: vev.wire.v1.ServerEnvelope.ui_view_update:type_name -> vev.wire.v1.UIViewUpdate |
| 1586 | - | 53, // 51: vev.wire.v1.ServerEnvelope.navigation_inventory_demand:type_name -> vev.wire.v1.NavigationInventoryDemand | 1604 | + | 53, // 51: vev.wire.v1.ServerEnvelope.navigation_inventory_response:type_name -> vev.wire.v1.NavigationInventoryResponse |
| 1587 | - | 54, // 52: vev.wire.v1.ServerEnvelope.navigation_inventory_selection:type_name -> vev.wire.v1.NavigationInventorySelection | 1605 | + | 54, // 52: vev.wire.v1.ServerEnvelope.navigation_inventory_demand:type_name -> vev.wire.v1.NavigationInventoryDemand |
| 1588 | - | 55, // 53: vev.wire.v1.ServerEnvelope.picker_offer:type_name -> vev.wire.v1.PickerOffer | 1606 | + | 55, // 53: vev.wire.v1.ServerEnvelope.navigation_inventory_selection:type_name -> vev.wire.v1.NavigationInventorySelection |
| 1589 | - | 56, // 54: vev.wire.v1.ServerEnvelope.picker_snapshot:type_name -> vev.wire.v1.PickerSnapshot | 1607 | + | 56, // 54: vev.wire.v1.ServerEnvelope.picker_offer:type_name -> vev.wire.v1.PickerOffer |
| 1590 | - | 57, // 55: vev.wire.v1.ServerEnvelope.picker_closed:type_name -> vev.wire.v1.PickerClosed | 1608 | + | 57, // 55: vev.wire.v1.ServerEnvelope.picker_snapshot:type_name -> vev.wire.v1.PickerSnapshot |
| 1591 | - | 58, // 56: vev.wire.v1.ServerEnvelope.picker_result:type_name -> vev.wire.v1.PickerResult | 1609 | + | 58, // 56: vev.wire.v1.ServerEnvelope.picker_closed:type_name -> vev.wire.v1.PickerClosed |
| 1592 | - | 59, // 57: vev.wire.v1.ServerEnvelope.picker_failure:type_name -> vev.wire.v1.PickerFailure | 1610 | + | 59, // 57: vev.wire.v1.ServerEnvelope.picker_result:type_name -> vev.wire.v1.PickerResult |
| 1593 | - | 60, // 58: vev.wire.v1.ServerEnvelope.attachment_suspended:type_name -> vev.wire.v1.AttachmentSuspended | 1611 | + | 60, // 58: vev.wire.v1.ServerEnvelope.picker_failure:type_name -> vev.wire.v1.PickerFailure |
| 1594 | - | 61, // 59: vev.wire.v1.ServerEnvelope.attachment_activated:type_name -> vev.wire.v1.AttachmentActivated | 1612 | + | 61, // 59: vev.wire.v1.ServerEnvelope.attachment_suspended:type_name -> vev.wire.v1.AttachmentSuspended |
| 1595 | - | 62, // 60: vev.wire.v1.ServerEnvelope.kill_result:type_name -> vev.wire.v1.KillResult | 1613 | + | 62, // 60: vev.wire.v1.ServerEnvelope.attachment_activated:type_name -> vev.wire.v1.AttachmentActivated |
| 1596 | - | 61, // [61:61] is the sub-list for method output_type | 1614 | + | 63, // 61: vev.wire.v1.ServerEnvelope.kill_result:type_name -> vev.wire.v1.KillResult |
| 1597 | - | 61, // [61:61] is the sub-list for method input_type | 1615 | + | 62, // [62:62] is the sub-list for method output_type |
| 1598 | - | 61, // [61:61] is the sub-list for extension type_name | 1616 | + | 62, // [62:62] is the sub-list for method input_type |
| 1599 | - | 61, // [61:61] is the sub-list for extension extendee | 1617 | + | 62, // [62:62] is the sub-list for extension type_name |
| 1600 | - | 0, // [0:61] is the sub-list for field type_name | 1618 | + | 62, // [62:62] is the sub-list for extension extendee |
| 1619 | + | 0, // [0:62] is the sub-list for field type_name | |||
| 1601 | } | 1620 | } | ||
| 1602 | 1621 | ||||
| 1603 | func init() { file_envelope_proto_init() } | 1622 | func init() { file_envelope_proto_init() } | ||
| @@ -1640,6 +1659,7 @@ func file_envelope_proto_init() { | |||||
| 1640 | (*ClientEnvelope_SelectTab)(nil), | 1659 | (*ClientEnvelope_SelectTab)(nil), | ||
| 1641 | (*ClientEnvelope_RemotePreviewWatch)(nil), | 1660 | (*ClientEnvelope_RemotePreviewWatch)(nil), | ||
| 1642 | (*ClientEnvelope_TerminalFocus)(nil), | 1661 | (*ClientEnvelope_TerminalFocus)(nil), | ||
| 1662 | + | (*ClientEnvelope_ModifierState)(nil), | |||
| 1643 | } | 1663 | } | ||
| 1644 | file_envelope_proto_msgTypes[5].OneofWrappers = []any{ | 1664 | file_envelope_proto_msgTypes[5].OneofWrappers = []any{ | ||
| 1645 | (*ServerEnvelope_Welcome)(nil), | 1665 | (*ServerEnvelope_Welcome)(nil), | ||
M internal/protocol/wire/envelope_inventory_test.go +3 -3
| @@ -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 != 66 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 66", protocol.Version) | |
| 18 | + | if protocol.Version != 67 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 67", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||
| 40 | 40 | "ui_fence": true, "navigation_inventory_request": true, | |
| 41 | 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | |
| 42 | 42 | "picker_close": true, "picker_selection": true, | |
| 43 | - | "select_tab": true, "terminal_focus": true, | |
| 43 | + | "select_tab": true, "terminal_focus": true, "modifier_state": true, | |
| 44 | 44 | } | |
| 45 | 45 | serverSemantic := map[string]bool{ | |
| 46 | 46 | "attachment_suspended": true, "attachment_activated": true, | |
| @@ -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 != 66 { | 18 | + | if protocol.Version != 67 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 66", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 67", 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) | ||
| @@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||||
| 40 | "ui_fence": true, "navigation_inventory_request": true, | 40 | "ui_fence": true, "navigation_inventory_request": true, | ||
| 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | ||
| 42 | "picker_close": true, "picker_selection": true, | 42 | "picker_close": true, "picker_selection": true, | ||
| 43 | - | "select_tab": true, "terminal_focus": true, | 43 | + | "select_tab": true, "terminal_focus": true, "modifier_state": true, |
| 44 | } | 44 | } | ||
| 45 | serverSemantic := map[string]bool{ | 45 | serverSemantic := map[string]bool{ | ||
| 46 | "attachment_suspended": true, "attachment_activated": true, | 46 | "attachment_suspended": true, "attachment_activated": true, | ||
M internal/protocol/wire/schema/envelope.proto +1 -0
| @@ -100,6 +100,7 @@ message ClientEnvelope { | |||
| 100 | 100 | SelectTab select_tab = 31; | |
| 101 | 101 | RemotePreviewWatch remote_preview_watch = 32; | |
| 102 | 102 | TerminalFocus terminal_focus = 33; | |
| 103 | + | ModifierState modifier_state = 34; | |
| 103 | 104 | } | |
| 104 | 105 | } | |
| 105 | 106 | ||
| @@ -100,6 +100,7 @@ message ClientEnvelope { | |||||
| 100 | SelectTab select_tab = 31; | 100 | SelectTab select_tab = 31; | ||
| 101 | RemotePreviewWatch remote_preview_watch = 32; | 101 | RemotePreviewWatch remote_preview_watch = 32; | ||
| 102 | TerminalFocus terminal_focus = 33; | 102 | TerminalFocus terminal_focus = 33; | ||
| 103 | + | ModifierState modifier_state = 34; | |||
| 103 | } | 104 | } | ||
| 104 | } | 105 | } | ||
| 105 | 106 | ||||
M internal/protocol/wire/schema/terminal.proto +6 -0
| @@ -93,6 +93,12 @@ message TerminalFocus { | |||
| 93 | 93 | uint32 focus = 1; | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | // ModifierState reports which held modifier keys the client's terminal | |
| 97 | + | // reports on their own. | |
| 98 | + | message ModifierState { | |
| 99 | + | bool ctrl = 1; | |
| 100 | + | } | |
| 101 | + | ||
| 96 | 102 | // SelectTab shows one exact tab of the attached session on this attachment. | |
| 97 | 103 | message SelectTab { | |
| 98 | 104 | string tab_id = 1; | |
| @@ -93,6 +93,12 @@ message TerminalFocus { | |||||
| 93 | uint32 focus = 1; | 93 | uint32 focus = 1; | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | // ModifierState reports which held modifier keys the client's terminal | |||
| 97 | + | // reports on their own. | |||
| 98 | + | message ModifierState { | |||
| 99 | + | bool ctrl = 1; | |||
| 100 | + | } | |||
| 101 | + | ||||
| 96 | // SelectTab shows one exact tab of the attached session on this attachment. | 102 | // SelectTab shows one exact tab of the attached session on this attachment. | ||
| 97 | message SelectTab { | 103 | message SelectTab { | ||
| 98 | string tab_id = 1; | 104 | string tab_id = 1; | ||
M internal/protocol/wire/terminal.pb.go +93 -44 generated file: collapsed
M internal/usecase/client/attachment_session.go +17 -0
| @@ -386,6 +386,15 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 386 | 386 | if err := sendFocus(); err != nil { | |
| 387 | 387 | return w.settle(ctx, fg, stream, token, err) | |
| 388 | 388 | } | |
| 389 | + | // A Ctrl held on its own shows the recent-session ranks. | |
| 390 | + | ctrl := newCtrlReporter(attachmentTerminalCtrl(fg), w.cfg.Clock) | |
| 391 | + | defer ctrl.stop() | |
| 392 | + | sendCtrl := func(message protocol.ModifierState, ok bool) error { | |
| 393 | + | if ok { | |
| 394 | + | return w.send(ctx, fg, stream, message) | |
| 395 | + | } | |
| 396 | + | return nil | |
| 397 | + | } | |
| 389 | 398 | // pending is the one undecided input delivery waiting on the outbox. | |
| 390 | 399 | var pending *pendingInputAck | |
| 391 | 400 | defer func() { | |
| @@ -458,6 +467,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 458 | 467 | if err := sendFocus(); err != nil { | |
| 459 | 468 | return w.settle(ctx, fg, stream, token, err) | |
| 460 | 469 | } | |
| 470 | + | case <-ctrl.changed: | |
| 471 | + | if err := sendCtrl(ctrl.next()); err != nil { | |
| 472 | + | return w.settle(ctx, fg, stream, token, err) | |
| 473 | + | } | |
| 474 | + | case <-ctrl.timerC(): | |
| 475 | + | if err := sendCtrl(ctrl.due()); err != nil { | |
| 476 | + | return w.settle(ctx, fg, stream, token, err) | |
| 477 | + | } | |
| 461 | 478 | case <-input.wake: | |
| 462 | 479 | if err := input.flush(ctx); err != nil { | |
| 463 | 480 | return w.settle(ctx, fg, stream, token, err) | |
| @@ -386,6 +386,15 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 386 | if err := sendFocus(); err != nil { | 386 | if err := sendFocus(); err != nil { | ||
| 387 | return w.settle(ctx, fg, stream, token, err) | 387 | return w.settle(ctx, fg, stream, token, err) | ||
| 388 | } | 388 | } | ||
| 389 | + | // A Ctrl held on its own shows the recent-session ranks. | |||
| 390 | + | ctrl := newCtrlReporter(attachmentTerminalCtrl(fg), w.cfg.Clock) | |||
| 391 | + | defer ctrl.stop() | |||
| 392 | + | sendCtrl := func(message protocol.ModifierState, ok bool) error { | |||
| 393 | + | if ok { | |||
| 394 | + | return w.send(ctx, fg, stream, message) | |||
| 395 | + | } | |||
| 396 | + | return nil | |||
| 397 | + | } | |||
| 389 | // pending is the one undecided input delivery waiting on the outbox. | 398 | // pending is the one undecided input delivery waiting on the outbox. | ||
| 390 | var pending *pendingInputAck | 399 | var pending *pendingInputAck | ||
| 391 | defer func() { | 400 | defer func() { | ||
| @@ -458,6 +467,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 458 | if err := sendFocus(); err != nil { | 467 | if err := sendFocus(); err != nil { | ||
| 459 | return w.settle(ctx, fg, stream, token, err) | 468 | return w.settle(ctx, fg, stream, token, err) | ||
| 460 | } | 469 | } | ||
| 470 | + | case <-ctrl.changed: | |||
| 471 | + | if err := sendCtrl(ctrl.next()); err != nil { | |||
| 472 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 473 | + | } | |||
| 474 | + | case <-ctrl.timerC(): | |||
| 475 | + | if err := sendCtrl(ctrl.due()); err != nil { | |||
| 476 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 477 | + | } | |||
| 461 | case <-input.wake: | 478 | case <-input.wake: | ||
| 462 | if err := input.flush(ctx); err != nil { | 479 | if err := input.flush(ctx); err != nil { | ||
| 463 | return w.settle(ctx, fg, stream, token, err) | 480 | return w.settle(ctx, fg, stream, token, err) | ||
M internal/usecase/client/supervisor.go +4 -1
| @@ -1723,7 +1723,10 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi | |||
| 1723 | 1723 | } | |
| 1724 | 1724 | caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump) | |
| 1725 | 1725 | if caps.KittyKeyboard && env.KittyKeyboard { | |
| 1726 | - | if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil { | |
| 1726 | + | // Normalize before the terminal switches, so no read escapes it. | |
| 1727 | + | pump.keys.enable() | |
| 1728 | + | if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil { | |
| 1729 | + | pump.keys.disable() | |
| 1727 | 1730 | s.logger.Warn("enabling kitty keyboard protocol failed", "err", err) | |
| 1728 | 1731 | caps.KittyKeyboard = false | |
| 1729 | 1732 | } | |
| @@ -1723,7 +1723,10 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi | |||||
| 1723 | } | 1723 | } | ||
| 1724 | caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump) | 1724 | caps := probeTerminalCapabilities(ctx, s.cfg.Terminal, s.cfg.Clock, pump) | ||
| 1725 | if caps.KittyKeyboard && env.KittyKeyboard { | 1725 | if caps.KittyKeyboard && env.KittyKeyboard { | ||
| 1726 | - | if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.OuterFlags); err != nil { | 1726 | + | // Normalize before the terminal switches, so no read escapes it. |
| 1727 | + | pump.keys.enable() | |||
| 1728 | + | if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil { | |||
| 1729 | + | pump.keys.disable() | |||
| 1727 | s.logger.Warn("enabling kitty keyboard protocol failed", "err", err) | 1730 | s.logger.Warn("enabling kitty keyboard protocol failed", "err", err) | ||
| 1728 | caps.KittyKeyboard = false | 1731 | caps.KittyKeyboard = false | ||
| 1729 | } | 1732 | } | ||
M internal/usecase/client/terminal_input_pump.go +9 -3
| @@ -51,7 +51,10 @@ type terminalInputPump struct { | |||
| 51 | 51 | in io.Reader | |
| 52 | 52 | // focus is the terminal focus decoded from this reader. The pump strips | |
| 53 | 53 | // focus reports before any consumer sees the bytes. | |
| 54 | - | focus *TerminalFocusState | |
| 54 | + | focus *TerminalFocusState | |
| 55 | + | // keys rewrites kitty key events to the encoding consumers read and | |
| 56 | + | // tracks a held Ctrl, once the terminal reports every key event. | |
| 57 | + | keys *terminalKeys | |
| 55 | 58 | done chan struct{} | |
| 56 | 59 | automation chan terminalAutomationRequest | |
| 57 | 60 | ||
| @@ -91,6 +94,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 91 | 94 | return &terminalInputPump{ | |
| 92 | 95 | in: in, | |
| 93 | 96 | focus: newTerminalFocusState(), | |
| 97 | + | keys: newTerminalKeys(), | |
| 94 | 98 | done: make(chan struct{}), | |
| 95 | 99 | automation: make(chan terminalAutomationRequest), | |
| 96 | 100 | activation: 1, | |
| @@ -392,10 +396,12 @@ func (p *terminalInputPump) start() { | |||
| 392 | 396 | n, err := p.in.Read(buf) | |
| 393 | 397 | result := terminalReadResult{err: err} | |
| 394 | 398 | if n > 0 { | |
| 395 | - | if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 { | |
| 399 | + | data := stripTerminalFocusReports(p.focus, buf[:n]) | |
| 400 | + | if data = p.keys.normalize(data, p.focus.Focus()); len(data) != 0 { | |
| 396 | 401 | result.data = append([]byte(nil), data...) | |
| 397 | 402 | } else if err == nil { | |
| 398 | - | // A read that only carried focus reports delivers nothing. | |
| 403 | + | // A read that only carried focus reports or dropped key | |
| 404 | + | // events delivers nothing. | |
| 399 | 405 | continue | |
| 400 | 406 | } | |
| 401 | 407 | } | |
| @@ -51,7 +51,10 @@ type terminalInputPump struct { | |||||
| 51 | in io.Reader | 51 | in io.Reader | ||
| 52 | // focus is the terminal focus decoded from this reader. The pump strips | 52 | // focus is the terminal focus decoded from this reader. The pump strips | ||
| 53 | // focus reports before any consumer sees the bytes. | 53 | // focus reports before any consumer sees the bytes. | ||
| 54 | - | focus *TerminalFocusState | 54 | + | focus *TerminalFocusState |
| 55 | + | // keys rewrites kitty key events to the encoding consumers read and | |||
| 56 | + | // tracks a held Ctrl, once the terminal reports every key event. | |||
| 57 | + | keys *terminalKeys | |||
| 55 | done chan struct{} | 58 | done chan struct{} | ||
| 56 | automation chan terminalAutomationRequest | 59 | automation chan terminalAutomationRequest | ||
| 57 | 60 | ||||
| @@ -91,6 +94,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||||
| 91 | return &terminalInputPump{ | 94 | return &terminalInputPump{ | ||
| 92 | in: in, | 95 | in: in, | ||
| 93 | focus: newTerminalFocusState(), | 96 | focus: newTerminalFocusState(), | ||
| 97 | + | keys: newTerminalKeys(), | |||
| 94 | done: make(chan struct{}), | 98 | done: make(chan struct{}), | ||
| 95 | automation: make(chan terminalAutomationRequest), | 99 | automation: make(chan terminalAutomationRequest), | ||
| 96 | activation: 1, | 100 | activation: 1, | ||
| @@ -392,10 +396,12 @@ func (p *terminalInputPump) start() { | |||||
| 392 | n, err := p.in.Read(buf) | 396 | n, err := p.in.Read(buf) | ||
| 393 | result := terminalReadResult{err: err} | 397 | result := terminalReadResult{err: err} | ||
| 394 | if n > 0 { | 398 | if n > 0 { | ||
| 395 | - | if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 { | 399 | + | data := stripTerminalFocusReports(p.focus, buf[:n]) |
| 400 | + | if data = p.keys.normalize(data, p.focus.Focus()); len(data) != 0 { | |||
| 396 | result.data = append([]byte(nil), data...) | 401 | result.data = append([]byte(nil), data...) | ||
| 397 | } else if err == nil { | 402 | } else if err == nil { | ||
| 398 | - | // A read that only carried focus reports delivers nothing. | 403 | + | // A read that only carried focus reports or dropped key |
| 404 | + | // events delivers nothing. | |||
| 399 | continue | 405 | continue | ||
| 400 | } | 406 | } | ||
| 401 | } | 407 | } | ||
A internal/usecase/client/terminal_keys.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Held Ctrl. | |
| 15 | + | // | |
| 16 | + | // With the kitty keyboard protocol the client asks its terminal for every key | |
| 17 | + | // event, modifier presses and releases included (kittykey.RequestFlags). The | |
| 18 | + | // terminal input pump rewrites that stream back to the encoding the rest of | |
| 19 | + | // vev reads (kittykey.OuterFlags) and records whether Ctrl is held on its own | |
| 20 | + | // in a terminalCtrlState. The attached loop reports a hold that lasted | |
| 21 | + | // ctrlHoldDelay to the daemon, which then shows recent-session ranks. | |
| 22 | + | ||
| 23 | + | // ctrlHoldDelay keeps a quick Ctrl shortcut from flashing the ranks. | |
| 24 | + | const ctrlHoldDelay = 150 * time.Millisecond | |
| 25 | + | ||
| 26 | + | // terminalKeys is the pump's key normalization. Only the pump's reader | |
| 27 | + | // goroutine calls normalize; enable may run on any goroutine. | |
| 28 | + | type terminalKeys struct { | |
| 29 | + | enabled atomic.Bool | |
| 30 | + | normalizer kittykey.Normalizer | |
| 31 | + | ctrl *terminalCtrlState | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newTerminalKeys() *terminalKeys { | |
| 35 | + | return &terminalKeys{ctrl: newTerminalCtrlState()} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // enable starts normalizing. Call it before asking the terminal for | |
| 39 | + | // kittykey.RequestFlags; input still in the old encoding passes unchanged. | |
| 40 | + | func (k *terminalKeys) enable() { k.enabled.Store(true) } | |
| 41 | + | ||
| 42 | + | // disable stops normalizing, for a terminal that refused the flags. | |
| 43 | + | func (k *terminalKeys) disable() { k.enabled.Store(false) } | |
| 44 | + | ||
| 45 | + | // normalize rewrites one read. focus is the terminal focus after the read: | |
| 46 | + | // a window that lost focus may never report the Ctrl release. | |
| 47 | + | func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte { | |
| 48 | + | if !k.enabled.Load() { | |
| 49 | + | return data | |
| 50 | + | } | |
| 51 | + | out := k.normalizer.Normalize(data) | |
| 52 | + | if focus == domain.TerminalFocusUnfocused { | |
| 53 | + | k.normalizer.ReleaseCtrl() | |
| 54 | + | } | |
| 55 | + | k.ctrl.set(k.normalizer.CtrlHeld()) | |
| 56 | + | return out | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // terminalCtrlState is the client's one held-Ctrl state. The terminal input | |
| 60 | + | // pump is its only writer; it outlives attachments. | |
| 61 | + | type terminalCtrlState struct { | |
| 62 | + | mu sync.Mutex | |
| 63 | + | held bool | |
| 64 | + | changed chan struct{} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func newTerminalCtrlState() *terminalCtrlState { | |
| 68 | + | return &terminalCtrlState{changed: make(chan struct{})} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Watch returns whether Ctrl is held and a channel closed on the next change. | |
| 72 | + | // A nil state is never held and never changes. | |
| 73 | + | func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) { | |
| 74 | + | if s == nil { | |
| 75 | + | return false, nil | |
| 76 | + | } | |
| 77 | + | s.mu.Lock() | |
| 78 | + | defer s.mu.Unlock() | |
| 79 | + | return s.held, s.changed | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (s *terminalCtrlState) set(held bool) { | |
| 83 | + | s.mu.Lock() | |
| 84 | + | defer s.mu.Unlock() | |
| 85 | + | if held == s.held { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | s.held = held | |
| 89 | + | close(s.changed) | |
| 90 | + | s.changed = make(chan struct{}) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // attachmentCtrlForeground is the optional seam through which an attached | |
| 94 | + | // loop reads the held-Ctrl state. | |
| 95 | + | type attachmentCtrlForeground interface { | |
| 96 | + | terminalCtrl() *terminalCtrlState | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (f *attachmentForeground) terminalCtrl() *terminalCtrlState { | |
| 100 | + | if f == nil || f.input == nil { | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | return f.input.keys.ctrl | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState { | |
| 107 | + | if ctrl, ok := fg.(attachmentCtrlForeground); ok { | |
| 108 | + | return ctrl.terminalCtrl() | |
| 109 | + | } | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold | |
| 114 | + | // is reported once it lasted ctrlHoldDelay, a release at once, and only after | |
| 115 | + | // a reported hold. | |
| 116 | + | type ctrlReporter struct { | |
| 117 | + | state *terminalCtrlState | |
| 118 | + | clock ports.Clock | |
| 119 | + | sent bool | |
| 120 | + | changed <-chan struct{} | |
| 121 | + | timer ports.Timer | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter { | |
| 125 | + | r := &ctrlReporter{state: state, clock: clock} | |
| 126 | + | r.next() | |
| 127 | + | return r | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // timerC fires when a pending hold is due. | |
| 131 | + | func (r *ctrlReporter) timerC() <-chan time.Time { | |
| 132 | + | if r.timer == nil { | |
| 133 | + | return nil | |
| 134 | + | } | |
| 135 | + | return r.timer.C() | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // next handles a state change and returns the message to send now, if any. | |
| 139 | + | func (r *ctrlReporter) next() (protocol.ModifierState, bool) { | |
| 140 | + | held, changed := r.state.Watch() | |
| 141 | + | r.changed = changed | |
| 142 | + | if held { | |
| 143 | + | if !r.sent && r.timer == nil { | |
| 144 | + | r.timer = r.clock.NewTimer(ctrlHoldDelay) | |
| 145 | + | } | |
| 146 | + | return protocol.ModifierState{}, false | |
| 147 | + | } | |
| 148 | + | r.stop() | |
| 149 | + | if !r.sent { | |
| 150 | + | return protocol.ModifierState{}, false | |
| 151 | + | } | |
| 152 | + | r.sent = false | |
| 153 | + | return protocol.ModifierState{Ctrl: false}, true | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // due handles the hold timer and returns the hold to report, if still held. | |
| 157 | + | func (r *ctrlReporter) due() (protocol.ModifierState, bool) { | |
| 158 | + | r.stop() | |
| 159 | + | if held, _ := r.state.Watch(); !held || r.sent { | |
| 160 | + | return protocol.ModifierState{}, false | |
| 161 | + | } | |
| 162 | + | r.sent = true | |
| 163 | + | return protocol.ModifierState{Ctrl: true}, true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (r *ctrlReporter) stop() { | |
| 167 | + | if r.timer != nil { | |
| 168 | + | r.timer.Stop() | |
| 169 | + | r.timer = nil | |
| 170 | + | } | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Held Ctrl. | |||
| 15 | + | // | |||
| 16 | + | // With the kitty keyboard protocol the client asks its terminal for every key | |||
| 17 | + | // event, modifier presses and releases included (kittykey.RequestFlags). The | |||
| 18 | + | // terminal input pump rewrites that stream back to the encoding the rest of | |||
| 19 | + | // vev reads (kittykey.OuterFlags) and records whether Ctrl is held on its own | |||
| 20 | + | // in a terminalCtrlState. The attached loop reports a hold that lasted | |||
| 21 | + | // ctrlHoldDelay to the daemon, which then shows recent-session ranks. | |||
| 22 | + | ||||
| 23 | + | // ctrlHoldDelay keeps a quick Ctrl shortcut from flashing the ranks. | |||
| 24 | + | const ctrlHoldDelay = 150 * time.Millisecond | |||
| 25 | + | ||||
| 26 | + | // terminalKeys is the pump's key normalization. Only the pump's reader | |||
| 27 | + | // goroutine calls normalize; enable may run on any goroutine. | |||
| 28 | + | type terminalKeys struct { | |||
| 29 | + | enabled atomic.Bool | |||
| 30 | + | normalizer kittykey.Normalizer | |||
| 31 | + | ctrl *terminalCtrlState | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func newTerminalKeys() *terminalKeys { | |||
| 35 | + | return &terminalKeys{ctrl: newTerminalCtrlState()} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // enable starts normalizing. Call it before asking the terminal for | |||
| 39 | + | // kittykey.RequestFlags; input still in the old encoding passes unchanged. | |||
| 40 | + | func (k *terminalKeys) enable() { k.enabled.Store(true) } | |||
| 41 | + | ||||
| 42 | + | // disable stops normalizing, for a terminal that refused the flags. | |||
| 43 | + | func (k *terminalKeys) disable() { k.enabled.Store(false) } | |||
| 44 | + | ||||
| 45 | + | // normalize rewrites one read. focus is the terminal focus after the read: | |||
| 46 | + | // a window that lost focus may never report the Ctrl release. | |||
| 47 | + | func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte { | |||
| 48 | + | if !k.enabled.Load() { | |||
| 49 | + | return data | |||
| 50 | + | } | |||
| 51 | + | out := k.normalizer.Normalize(data) | |||
| 52 | + | if focus == domain.TerminalFocusUnfocused { | |||
| 53 | + | k.normalizer.ReleaseCtrl() | |||
| 54 | + | } | |||
| 55 | + | k.ctrl.set(k.normalizer.CtrlHeld()) | |||
| 56 | + | return out | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // terminalCtrlState is the client's one held-Ctrl state. The terminal input | |||
| 60 | + | // pump is its only writer; it outlives attachments. | |||
| 61 | + | type terminalCtrlState struct { | |||
| 62 | + | mu sync.Mutex | |||
| 63 | + | held bool | |||
| 64 | + | changed chan struct{} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func newTerminalCtrlState() *terminalCtrlState { | |||
| 68 | + | return &terminalCtrlState{changed: make(chan struct{})} | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Watch returns whether Ctrl is held and a channel closed on the next change. | |||
| 72 | + | // A nil state is never held and never changes. | |||
| 73 | + | func (s *terminalCtrlState) Watch() (bool, <-chan struct{}) { | |||
| 74 | + | if s == nil { | |||
| 75 | + | return false, nil | |||
| 76 | + | } | |||
| 77 | + | s.mu.Lock() | |||
| 78 | + | defer s.mu.Unlock() | |||
| 79 | + | return s.held, s.changed | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (s *terminalCtrlState) set(held bool) { | |||
| 83 | + | s.mu.Lock() | |||
| 84 | + | defer s.mu.Unlock() | |||
| 85 | + | if held == s.held { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | s.held = held | |||
| 89 | + | close(s.changed) | |||
| 90 | + | s.changed = make(chan struct{}) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // attachmentCtrlForeground is the optional seam through which an attached | |||
| 94 | + | // loop reads the held-Ctrl state. | |||
| 95 | + | type attachmentCtrlForeground interface { | |||
| 96 | + | terminalCtrl() *terminalCtrlState | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (f *attachmentForeground) terminalCtrl() *terminalCtrlState { | |||
| 100 | + | if f == nil || f.input == nil { | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | return f.input.keys.ctrl | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func attachmentTerminalCtrl(fg AttachmentForeground) *terminalCtrlState { | |||
| 107 | + | if ctrl, ok := fg.(attachmentCtrlForeground); ok { | |||
| 108 | + | return ctrl.terminalCtrl() | |||
| 109 | + | } | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // ctrlReporter turns the held-Ctrl state into ModifierState messages: a hold | |||
| 114 | + | // is reported once it lasted ctrlHoldDelay, a release at once, and only after | |||
| 115 | + | // a reported hold. | |||
| 116 | + | type ctrlReporter struct { | |||
| 117 | + | state *terminalCtrlState | |||
| 118 | + | clock ports.Clock | |||
| 119 | + | sent bool | |||
| 120 | + | changed <-chan struct{} | |||
| 121 | + | timer ports.Timer | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func newCtrlReporter(state *terminalCtrlState, clock ports.Clock) *ctrlReporter { | |||
| 125 | + | r := &ctrlReporter{state: state, clock: clock} | |||
| 126 | + | r.next() | |||
| 127 | + | return r | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // timerC fires when a pending hold is due. | |||
| 131 | + | func (r *ctrlReporter) timerC() <-chan time.Time { | |||
| 132 | + | if r.timer == nil { | |||
| 133 | + | return nil | |||
| 134 | + | } | |||
| 135 | + | return r.timer.C() | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // next handles a state change and returns the message to send now, if any. | |||
| 139 | + | func (r *ctrlReporter) next() (protocol.ModifierState, bool) { | |||
| 140 | + | held, changed := r.state.Watch() | |||
| 141 | + | r.changed = changed | |||
| 142 | + | if held { | |||
| 143 | + | if !r.sent && r.timer == nil { | |||
| 144 | + | r.timer = r.clock.NewTimer(ctrlHoldDelay) | |||
| 145 | + | } | |||
| 146 | + | return protocol.ModifierState{}, false | |||
| 147 | + | } | |||
| 148 | + | r.stop() | |||
| 149 | + | if !r.sent { | |||
| 150 | + | return protocol.ModifierState{}, false | |||
| 151 | + | } | |||
| 152 | + | r.sent = false | |||
| 153 | + | return protocol.ModifierState{Ctrl: false}, true | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // due handles the hold timer and returns the hold to report, if still held. | |||
| 157 | + | func (r *ctrlReporter) due() (protocol.ModifierState, bool) { | |||
| 158 | + | r.stop() | |||
| 159 | + | if held, _ := r.state.Watch(); !held || r.sent { | |||
| 160 | + | return protocol.ModifierState{}, false | |||
| 161 | + | } | |||
| 162 | + | r.sent = true | |||
| 163 | + | return protocol.ModifierState{Ctrl: true}, true | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (r *ctrlReporter) stop() { | |||
| 167 | + | if r.timer != nil { | |||
| 168 | + | r.timer.Stop() | |||
| 169 | + | r.timer = nil | |||
| 170 | + | } | |||
| 171 | + | } | |||
A internal/usecase/client/terminal_keys_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestTerminalKeysNormalizeOnlyOnceEnabled(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | enabled bool | |
| 19 | + | focus domain.TerminalFocus | |
| 20 | + | in string | |
| 21 | + | want string | |
| 22 | + | wantHeld bool | |
| 23 | + | }{ | |
| 24 | + | {name: "disabled passes through", in: "\x1b[57442;5u", want: "\x1b[57442;5u"}, | |
| 25 | + | {name: "enabled drops ctrl press", enabled: true, in: "\x1b[57442;5u", want: "", wantHeld: true}, | |
| 26 | + | {name: "enabled rewrites text", enabled: true, in: "\x1b[97;;97u", want: "a"}, | |
| 27 | + | {name: "lost focus forgets ctrl", enabled: true, focus: domain.TerminalFocusUnfocused, in: "\x1b[57442;5u", want: ""}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | keys := newTerminalKeys() | |
| 32 | + | if tt.enabled { | |
| 33 | + | keys.enable() | |
| 34 | + | } | |
| 35 | + | require.Equal(t, tt.want, string(keys.normalize([]byte(tt.in), tt.focus))) | |
| 36 | + | held, _ := keys.ctrl.Watch() | |
| 37 | + | require.Equal(t, tt.wantHeld, held) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestCtrlReporterReportsHoldsAfterDelayAndReleasesAtOnce(t *testing.T) { | |
| 43 | + | type step struct { | |
| 44 | + | held bool // set the state, then handle the change | |
| 45 | + | fire bool // instead, fire the hold timer | |
| 46 | + | want *protocol.ModifierState | |
| 47 | + | } | |
| 48 | + | held, released := &protocol.ModifierState{Ctrl: true}, &protocol.ModifierState{} | |
| 49 | + | tests := []struct { | |
| 50 | + | name string | |
| 51 | + | steps []step | |
| 52 | + | }{ | |
| 53 | + | {name: "hold then release", steps: []step{{held: true}, {fire: true, want: held}, {held: false, want: released}}}, | |
| 54 | + | {name: "quick tap sends nothing", steps: []step{{held: true}, {held: false}, {fire: true}}}, | |
| 55 | + | {name: "hold reported once", steps: []step{{held: true}, {fire: true, want: held}, {fire: true}}}, | |
| 56 | + | } | |
| 57 | + | for _, tt := range tests { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | state := newTerminalCtrlState() | |
| 60 | + | clock := newSupervisorTestClock() | |
| 61 | + | r := newCtrlReporter(state, clock) | |
| 62 | + | var timer *supervisorTestTimer | |
| 63 | + | for i, s := range tt.steps { | |
| 64 | + | var message protocol.ModifierState | |
| 65 | + | var ok bool | |
| 66 | + | if s.fire { | |
| 67 | + | if r.timerC() == nil { | |
| 68 | + | require.Nil(t, s.want, "step %d: no timer to fire", i) | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | timer.ch <- time.Time{} | |
| 72 | + | <-r.timerC() | |
| 73 | + | message, ok = r.due() | |
| 74 | + | } else { | |
| 75 | + | state.set(s.held) | |
| 76 | + | <-r.changed | |
| 77 | + | message, ok = r.next() | |
| 78 | + | if s.held { | |
| 79 | + | timer = clock.awaitTimer(t) | |
| 80 | + | require.Equal(t, ctrlHoldDelay, timer.delay) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | require.Equal(t, s.want != nil, ok, "step %d", i) | |
| 84 | + | if s.want != nil { | |
| 85 | + | require.Equal(t, *s.want, message, "step %d", i) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | r.stop() | |
| 89 | + | require.True(t, clock.allStopped()) | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // TestAttachmentReportsHeldCtrl drives a real pump: once kitty key events are | |
| 95 | + | // enabled, a held Ctrl reaches the daemon as ModifierState and its release | |
| 96 | + | // follows, while the dropped modifier events never reach the session as input. | |
| 97 | + | func TestAttachmentReportsHeldCtrl(t *testing.T) { | |
| 98 | + | pr, pw := io.Pipe() | |
| 99 | + | defer pw.Close() | |
| 100 | + | pump := newTerminalInputPump(pr) | |
| 101 | + | pump.keys.enable() | |
| 102 | + | pump.start() | |
| 103 | + | defer pump.stop() | |
| 104 | + | stream := newSessionTestStream() | |
| 105 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |
| 106 | + | cfg := sessionTestWorkerConfig(sessionTestRequest(true)) | |
| 107 | + | clock := newSupervisorTestClock() | |
| 108 | + | cfg.Clock = clock | |
| 109 | + | worker, err := newSessionAttachmentWorker(cfg) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 112 | + | defer cancel() | |
| 113 | + | go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }() | |
| 114 | + | ||
| 115 | + | awaitHello(t, stream) | |
| 116 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 117 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 118 | + | ||
| 119 | + | sent := func(want protocol.ModifierState) func() bool { | |
| 120 | + | return func() bool { | |
| 121 | + | for _, message := range stream.messages() { | |
| 122 | + | if message == want { | |
| 123 | + | return true | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | _, err = pw.Write([]byte("\x1b[57442;5u")) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | var timer *supervisorTestTimer | |
| 132 | + | require.Eventually(t, func() bool { | |
| 133 | + | select { | |
| 134 | + | case created := <-clock.created: | |
| 135 | + | if created.delay == ctrlHoldDelay { | |
| 136 | + | timer = created | |
| 137 | + | } | |
| 138 | + | default: | |
| 139 | + | } | |
| 140 | + | return timer != nil | |
| 141 | + | }, 5*time.Second, time.Millisecond, "a Ctrl press arms the hold delay") | |
| 142 | + | timer.ch <- time.Time{} | |
| 143 | + | require.Eventually(t, sent(protocol.ModifierState{Ctrl: true}), 5*time.Second, time.Millisecond) | |
| 144 | + | ||
| 145 | + | _, err = pw.Write([]byte("\x1b[57442;1:3u")) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | require.Eventually(t, sent(protocol.ModifierState{}), 5*time.Second, time.Millisecond) | |
| 148 | + | for _, message := range stream.messages() { | |
| 149 | + | _, isInput := message.(protocol.Input) | |
| 150 | + | require.False(t, isInput, "modifier events are never session input") | |
| 151 | + | } | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestTerminalKeysNormalizeOnlyOnceEnabled(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | enabled bool | |||
| 19 | + | focus domain.TerminalFocus | |||
| 20 | + | in string | |||
| 21 | + | want string | |||
| 22 | + | wantHeld bool | |||
| 23 | + | }{ | |||
| 24 | + | {name: "disabled passes through", in: "\x1b[57442;5u", want: "\x1b[57442;5u"}, | |||
| 25 | + | {name: "enabled drops ctrl press", enabled: true, in: "\x1b[57442;5u", want: "", wantHeld: true}, | |||
| 26 | + | {name: "enabled rewrites text", enabled: true, in: "\x1b[97;;97u", want: "a"}, | |||
| 27 | + | {name: "lost focus forgets ctrl", enabled: true, focus: domain.TerminalFocusUnfocused, in: "\x1b[57442;5u", want: ""}, | |||
| 28 | + | } | |||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | keys := newTerminalKeys() | |||
| 32 | + | if tt.enabled { | |||
| 33 | + | keys.enable() | |||
| 34 | + | } | |||
| 35 | + | require.Equal(t, tt.want, string(keys.normalize([]byte(tt.in), tt.focus))) | |||
| 36 | + | held, _ := keys.ctrl.Watch() | |||
| 37 | + | require.Equal(t, tt.wantHeld, held) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestCtrlReporterReportsHoldsAfterDelayAndReleasesAtOnce(t *testing.T) { | |||
| 43 | + | type step struct { | |||
| 44 | + | held bool // set the state, then handle the change | |||
| 45 | + | fire bool // instead, fire the hold timer | |||
| 46 | + | want *protocol.ModifierState | |||
| 47 | + | } | |||
| 48 | + | held, released := &protocol.ModifierState{Ctrl: true}, &protocol.ModifierState{} | |||
| 49 | + | tests := []struct { | |||
| 50 | + | name string | |||
| 51 | + | steps []step | |||
| 52 | + | }{ | |||
| 53 | + | {name: "hold then release", steps: []step{{held: true}, {fire: true, want: held}, {held: false, want: released}}}, | |||
| 54 | + | {name: "quick tap sends nothing", steps: []step{{held: true}, {held: false}, {fire: true}}}, | |||
| 55 | + | {name: "hold reported once", steps: []step{{held: true}, {fire: true, want: held}, {fire: true}}}, | |||
| 56 | + | } | |||
| 57 | + | for _, tt := range tests { | |||
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 59 | + | state := newTerminalCtrlState() | |||
| 60 | + | clock := newSupervisorTestClock() | |||
| 61 | + | r := newCtrlReporter(state, clock) | |||
| 62 | + | var timer *supervisorTestTimer | |||
| 63 | + | for i, s := range tt.steps { | |||
| 64 | + | var message protocol.ModifierState | |||
| 65 | + | var ok bool | |||
| 66 | + | if s.fire { | |||
| 67 | + | if r.timerC() == nil { | |||
| 68 | + | require.Nil(t, s.want, "step %d: no timer to fire", i) | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | timer.ch <- time.Time{} | |||
| 72 | + | <-r.timerC() | |||
| 73 | + | message, ok = r.due() | |||
| 74 | + | } else { | |||
| 75 | + | state.set(s.held) | |||
| 76 | + | <-r.changed | |||
| 77 | + | message, ok = r.next() | |||
| 78 | + | if s.held { | |||
| 79 | + | timer = clock.awaitTimer(t) | |||
| 80 | + | require.Equal(t, ctrlHoldDelay, timer.delay) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | require.Equal(t, s.want != nil, ok, "step %d", i) | |||
| 84 | + | if s.want != nil { | |||
| 85 | + | require.Equal(t, *s.want, message, "step %d", i) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | r.stop() | |||
| 89 | + | require.True(t, clock.allStopped()) | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // TestAttachmentReportsHeldCtrl drives a real pump: once kitty key events are | |||
| 95 | + | // enabled, a held Ctrl reaches the daemon as ModifierState and its release | |||
| 96 | + | // follows, while the dropped modifier events never reach the session as input. | |||
| 97 | + | func TestAttachmentReportsHeldCtrl(t *testing.T) { | |||
| 98 | + | pr, pw := io.Pipe() | |||
| 99 | + | defer pw.Close() | |||
| 100 | + | pump := newTerminalInputPump(pr) | |||
| 101 | + | pump.keys.enable() | |||
| 102 | + | pump.start() | |||
| 103 | + | defer pump.stop() | |||
| 104 | + | stream := newSessionTestStream() | |||
| 105 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |||
| 106 | + | cfg := sessionTestWorkerConfig(sessionTestRequest(true)) | |||
| 107 | + | clock := newSupervisorTestClock() | |||
| 108 | + | cfg.Clock = clock | |||
| 109 | + | worker, err := newSessionAttachmentWorker(cfg) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 112 | + | defer cancel() | |||
| 113 | + | go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }() | |||
| 114 | + | ||||
| 115 | + | awaitHello(t, stream) | |||
| 116 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 117 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 118 | + | ||||
| 119 | + | sent := func(want protocol.ModifierState) func() bool { | |||
| 120 | + | return func() bool { | |||
| 121 | + | for _, message := range stream.messages() { | |||
| 122 | + | if message == want { | |||
| 123 | + | return true | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | _, err = pw.Write([]byte("\x1b[57442;5u")) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | var timer *supervisorTestTimer | |||
| 132 | + | require.Eventually(t, func() bool { | |||
| 133 | + | select { | |||
| 134 | + | case created := <-clock.created: | |||
| 135 | + | if created.delay == ctrlHoldDelay { | |||
| 136 | + | timer = created | |||
| 137 | + | } | |||
| 138 | + | default: | |||
| 139 | + | } | |||
| 140 | + | return timer != nil | |||
| 141 | + | }, 5*time.Second, time.Millisecond, "a Ctrl press arms the hold delay") | |||
| 142 | + | timer.ch <- time.Time{} | |||
| 143 | + | require.Eventually(t, sent(protocol.ModifierState{Ctrl: true}), 5*time.Second, time.Millisecond) | |||
| 144 | + | ||||
| 145 | + | _, err = pw.Write([]byte("\x1b[57442;1:3u")) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | require.Eventually(t, sent(protocol.ModifierState{}), 5*time.Second, time.Millisecond) | |||
| 148 | + | for _, message := range stream.messages() { | |||
| 149 | + | _, isInput := message.(protocol.Input) | |||
| 150 | + | require.False(t, isInput, "modifier events are never session input") | |||
| 151 | + | } | |||
| 152 | + | } | |||
M internal/usecase/daemon/attachment_suspension.go +3 -0
| @@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol. | |||
| 51 | 51 | ac.lifecycle.activity = attachmentSuspended | |
| 52 | 52 | ac.lifecycle.retained = target | |
| 53 | 53 | ac.lifecycle.mu.Unlock() | |
| 54 | + | // A suspended attachment drops modifier reports; its activation starts | |
| 55 | + | // from a released Ctrl and the client reports a hold afresh. | |
| 56 | + | ac.ctrlHeld.Store(false) | |
| 54 | 57 | sess.mu.Unlock() | |
| 55 | 58 | d.mu.Unlock() | |
| 56 | 59 | cleanup.finish() | |
| @@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol. | |||||
| 51 | ac.lifecycle.activity = attachmentSuspended | 51 | ac.lifecycle.activity = attachmentSuspended | ||
| 52 | ac.lifecycle.retained = target | 52 | ac.lifecycle.retained = target | ||
| 53 | ac.lifecycle.mu.Unlock() | 53 | ac.lifecycle.mu.Unlock() | ||
| 54 | + | // A suspended attachment drops modifier reports; its activation starts | |||
| 55 | + | // from a released Ctrl and the client reports a hold afresh. | |||
| 56 | + | ac.ctrlHeld.Store(false) | |||
| 54 | sess.mu.Unlock() | 57 | sess.mu.Unlock() | ||
| 55 | d.mu.Unlock() | 58 | d.mu.Unlock() | ||
| 56 | cleanup.finish() | 59 | cleanup.finish() | ||
M internal/usecase/daemon/client.go +3 -0
| @@ -112,6 +112,9 @@ type attachedClient struct { | |||
| 112 | 112 | // atomic so any daemon path reads it without taking a lock; see | |
| 113 | 113 | // terminalFocus. | |
| 114 | 114 | focus atomic.Uint32 | |
| 115 | + | // ctrlHeld is whether the client last reported Ctrl held on its own; the | |
| 116 | + | // status bar then shows recent-session ranks. | |
| 117 | + | ctrlHeld atomic.Bool | |
| 115 | 118 | // clientPID is the local client process from Hello, 0 when remote. It is | |
| 116 | 119 | // atomic because a resume replaces it while bar scripts read it. | |
| 117 | 120 | clientPID atomic.Uint32 | |
| @@ -112,6 +112,9 @@ type attachedClient struct { | |||||
| 112 | // atomic so any daemon path reads it without taking a lock; see | 112 | // atomic so any daemon path reads it without taking a lock; see | ||
| 113 | // terminalFocus. | 113 | // terminalFocus. | ||
| 114 | focus atomic.Uint32 | 114 | focus atomic.Uint32 | ||
| 115 | + | // ctrlHeld is whether the client last reported Ctrl held on its own; the | |||
| 116 | + | // status bar then shows recent-session ranks. | |||
| 117 | + | ctrlHeld atomic.Bool | |||
| 115 | // clientPID is the local client process from Hello, 0 when remote. It is | 118 | // clientPID is the local client process from Hello, 0 when remote. It is | ||
| 116 | // atomic because a resume replaces it while bar scripts read it. | 119 | // atomic because a resume replaces it while bar scripts read it. | ||
| 117 | clientPID atomic.Uint32 | 120 | clientPID atomic.Uint32 | ||
M internal/usecase/daemon/client_frame_routing.go +2 -0
| @@ -188,6 +188,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||
| 188 | 188 | d.selectTabForAttachment(effect, message) | |
| 189 | 189 | case protocol.TerminalFocus: | |
| 190 | 190 | d.applyTerminalFocusForAttachment(effect, message) | |
| 191 | + | case protocol.ModifierState: | |
| 192 | + | d.applyModifierStateForAttachment(effect, message) | |
| 191 | 193 | case protocol.RecentRouteSnapshot: | |
| 192 | 194 | replayIdentity := effect.ac.setRouteSnapshot(message) | |
| 193 | 195 | d.pokeAttentionTicker() | |
| @@ -188,6 +188,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||||
| 188 | d.selectTabForAttachment(effect, message) | 188 | d.selectTabForAttachment(effect, message) | ||
| 189 | case protocol.TerminalFocus: | 189 | case protocol.TerminalFocus: | ||
| 190 | d.applyTerminalFocusForAttachment(effect, message) | 190 | d.applyTerminalFocusForAttachment(effect, message) | ||
| 191 | + | case protocol.ModifierState: | |||
| 192 | + | d.applyModifierStateForAttachment(effect, message) | |||
| 191 | case protocol.RecentRouteSnapshot: | 193 | case protocol.RecentRouteSnapshot: | ||
| 192 | replayIdentity := effect.ac.setRouteSnapshot(message) | 194 | replayIdentity := effect.ac.setRouteSnapshot(message) | ||
| 193 | d.pokeAttentionTicker() | 195 | d.pokeAttentionTicker() | ||
A internal/usecase/daemon/modifier_state.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/protocol" | |
| 4 | + | ||
| 5 | + | // Held modifiers. | |
| 6 | + | // | |
| 7 | + | // A client whose terminal reports modifier key events tells the daemon when | |
| 8 | + | // Ctrl is held on its own. While it is, the attachment's status bar prefixes | |
| 9 | + | // each recent session with the rank Ctrl+1..9 jumps to. | |
| 10 | + | ||
| 11 | + | // applyModifierStateForAttachment records a reported Ctrl state and repaints | |
| 12 | + | // the attachment's bar when it changed. | |
| 13 | + | func (d *Daemon) applyModifierStateForAttachment(effect *attachmentEffect, message protocol.ModifierState) { | |
| 14 | + | if effect == nil || effect.sess == nil || effect.ac == nil { | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | if effect.ac.ctrlHeld.Swap(message.Ctrl) == message.Ctrl { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | d.invalidateRender(effect.sess, effect.ac, false, "modifier_state.go") | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/protocol" | |||
| 4 | + | ||||
| 5 | + | // Held modifiers. | |||
| 6 | + | // | |||
| 7 | + | // A client whose terminal reports modifier key events tells the daemon when | |||
| 8 | + | // Ctrl is held on its own. While it is, the attachment's status bar prefixes | |||
| 9 | + | // each recent session with the rank Ctrl+1..9 jumps to. | |||
| 10 | + | ||||
| 11 | + | // applyModifierStateForAttachment records a reported Ctrl state and repaints | |||
| 12 | + | // the attachment's bar when it changed. | |||
| 13 | + | func (d *Daemon) applyModifierStateForAttachment(effect *attachmentEffect, message protocol.ModifierState) { | |||
| 14 | + | if effect == nil || effect.sess == nil || effect.ac == nil { | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | if effect.ac.ctrlHeld.Swap(message.Ctrl) == message.Ctrl { | |||
| 18 | + | return | |||
| 19 | + | } | |||
| 20 | + | d.invalidateRender(effect.sess, effect.ac, false, "modifier_state.go") | |||
| 21 | + | } | |||
A internal/usecase/daemon/modifier_state_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // TestHeldCtrlShowsRecentSessionRanks pins that a reported Ctrl hold prefixes | |
| 14 | + | // the attachment's recent sessions with their Ctrl+N ranks, and its release | |
| 15 | + | // removes them. | |
| 16 | + | func TestHeldCtrlShowsRecentSessionRanks(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | ctrl bool | |
| 20 | + | ranks bool | |
| 21 | + | }{ | |
| 22 | + | {name: "held shows ranks", ctrl: true, ranks: true}, | |
| 23 | + | {name: "released hides ranks", ctrl: false, ranks: false}, | |
| 24 | + | } | |
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 28 | + | defer releases[0]() | |
| 29 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |
| 30 | + | effect := beginRecentRoutePaletteEffect(t, d, sess, ac) | |
| 31 | + | if !tt.ctrl { | |
| 32 | + | ac.ctrlHeld.Store(true) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | d.applyModifierStateForAttachment(effect, protocol.ModifierState{Ctrl: tt.ctrl}) | |
| 36 | + | ||
| 37 | + | require.Equal(t, tt.ctrl, ac.ctrlHeld.Load()) | |
| 38 | + | bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{}) | |
| 39 | + | require.Equal(t, tt.ranks, bars.showRanks) | |
| 40 | + | row := renderTestStatusRow(bars) | |
| 41 | + | require.Equal(t, tt.ranks, strings.Contains(row, "1:recent"), row) | |
| 42 | + | require.Equal(t, tt.ranks, strings.Contains(row, "2:older"), row) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func renderTestStatusRow(state barState) string { | |
| 48 | + | row := make([]renderer.Cell, 80) | |
| 49 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 50 | + | return rowText(row) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which | |
| 54 | + | // drops modifier reports, never activates with ranks stuck on. | |
| 55 | + | func TestSuspensionReleasesHeldCtrl(t *testing.T) { | |
| 56 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 57 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 58 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 59 | + | ac.installTestAttachmentCapability(token) | |
| 60 | + | ac.ctrlHeld.Store(true) | |
| 61 | + | ||
| 62 | + | require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1})) | |
| 63 | + | ||
| 64 | + | require.False(t, ac.ctrlHeld.Load()) | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // TestHeldCtrlShowsRecentSessionRanks pins that a reported Ctrl hold prefixes | |||
| 14 | + | // the attachment's recent sessions with their Ctrl+N ranks, and its release | |||
| 15 | + | // removes them. | |||
| 16 | + | func TestHeldCtrlShowsRecentSessionRanks(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | ctrl bool | |||
| 20 | + | ranks bool | |||
| 21 | + | }{ | |||
| 22 | + | {name: "held shows ranks", ctrl: true, ranks: true}, | |||
| 23 | + | {name: "released hides ranks", ctrl: false, ranks: false}, | |||
| 24 | + | } | |||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | d, sess, ac, _, releases := newManualTabSession(t, 1) | |||
| 28 | + | defer releases[0]() | |||
| 29 | + | ac.setRouteSnapshot(testRecentRouteSnapshot()) | |||
| 30 | + | effect := beginRecentRoutePaletteEffect(t, d, sess, ac) | |||
| 31 | + | if !tt.ctrl { | |||
| 32 | + | ac.ctrlHeld.Store(true) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | d.applyModifierStateForAttachment(effect, protocol.ModifierState{Ctrl: tt.ctrl}) | |||
| 36 | + | ||||
| 37 | + | require.Equal(t, tt.ctrl, ac.ctrlHeld.Load()) | |||
| 38 | + | bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{}) | |||
| 39 | + | require.Equal(t, tt.ranks, bars.showRanks) | |||
| 40 | + | row := renderTestStatusRow(bars) | |||
| 41 | + | require.Equal(t, tt.ranks, strings.Contains(row, "1:recent"), row) | |||
| 42 | + | require.Equal(t, tt.ranks, strings.Contains(row, "2:older"), row) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func renderTestStatusRow(state barState) string { | |||
| 48 | + | row := make([]renderer.Cell, 80) | |||
| 49 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |||
| 50 | + | return rowText(row) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which | |||
| 54 | + | // drops modifier reports, never activates with ranks stuck on. | |||
| 55 | + | func TestSuspensionReleasesHeldCtrl(t *testing.T) { | |||
| 56 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 57 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 58 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |||
| 59 | + | ac.installTestAttachmentCapability(token) | |||
| 60 | + | ac.ctrlHeld.Store(true) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1})) | |||
| 63 | + | ||||
| 64 | + | require.False(t, ac.ctrlHeld.Load()) | |||
| 65 | + | } | |||
M internal/usecase/daemon/recent_route_presentation_test.go +1 -1
| @@ -146,7 +146,7 @@ func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) { | |||
| 146 | 146 | ||
| 147 | 147 | for _, tt := range tests { | |
| 148 | 148 | t.Run(tt.name, func(t *testing.T) { | |
| 149 | - | got := fitMRU(tt.entries, tt.width, 0, "copy") | |
| 149 | + | got := fitMRU(tt.entries, tt.width, 0, "copy", false) | |
| 150 | 150 | names := make([]string, len(got)) | |
| 151 | 151 | for i, entry := range got { | |
| 152 | 152 | names[i] = entry.name | |
| @@ -146,7 +146,7 @@ func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) { | |||||
| 146 | 146 | ||||
| 147 | for _, tt := range tests { | 147 | for _, tt := range tests { | ||
| 148 | t.Run(tt.name, func(t *testing.T) { | 148 | t.Run(tt.name, func(t *testing.T) { | ||
| 149 | - | got := fitMRU(tt.entries, tt.width, 0, "copy") | 149 | + | got := fitMRU(tt.entries, tt.width, 0, "copy", false) |
| 150 | names := make([]string, len(got)) | 150 | names := make([]string, len(got)) | ||
| 151 | for i, entry := range got { | 151 | for i, entry := range got { | ||
| 152 | names[i] = entry.name | 152 | names[i] = entry.name | ||
M internal/usecase/daemon/resume.go +2 -0
| @@ -636,6 +636,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||
| 636 | 636 | d.reconfigureAttachmentOutput(sess, ac, h) | |
| 637 | 637 | // The replacement terminal's focus, too, is declared before first paint. | |
| 638 | 638 | ac.setTerminalFocus(h.TerminalFocus) | |
| 639 | + | // The replacement attachment reports a held Ctrl afresh. | |
| 640 | + | ac.ctrlHeld.Store(false) | |
| 639 | 641 | ac.clientPID.Store(h.ClientPID) | |
| 640 | 642 | ac.setClientEnvironment(helloClientEnvironment(h)) | |
| 641 | 643 | geometry := h.Geometry() | |
| @@ -636,6 +636,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||||
| 636 | d.reconfigureAttachmentOutput(sess, ac, h) | 636 | d.reconfigureAttachmentOutput(sess, ac, h) | ||
| 637 | // The replacement terminal's focus, too, is declared before first paint. | 637 | // The replacement terminal's focus, too, is declared before first paint. | ||
| 638 | ac.setTerminalFocus(h.TerminalFocus) | 638 | ac.setTerminalFocus(h.TerminalFocus) | ||
| 639 | + | // The replacement attachment reports a held Ctrl afresh. | |||
| 640 | + | ac.ctrlHeld.Store(false) | |||
| 639 | ac.clientPID.Store(h.ClientPID) | 641 | ac.clientPID.Store(h.ClientPID) | ||
| 640 | ac.setClientEnvironment(helloClientEnvironment(h)) | 642 | ac.setClientEnvironment(helloClientEnvironment(h)) | ||
| 641 | geometry := h.Geometry() | 643 | geometry := h.Geometry() | ||
M internal/usecase/daemon/status.go +30 -8
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | const ( | |
| 19 | + | // maxMRUSessions is how many history entries Ctrl+1..9 reach. | |
| 19 | 20 | maxMRUSessions = 9 | |
| 20 | 21 | pulseFrameCount = 30 | |
| 21 | 22 | pulseFrameInterval = 120 * time.Millisecond | |
| @@ -89,9 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||
| 89 | 90 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | |
| 90 | 91 | } | |
| 91 | 92 | } else { | |
| 92 | - | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 93 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks) | |
| 93 | 94 | for i, sess := range fittedMRU { | |
| 94 | 95 | style := styles.MRUStyle(i, len(fittedMRU)) | |
| 96 | + | if state.showRanks && i < maxMRUSessions { | |
| 97 | + | drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced) | |
| 98 | + | continue | |
| 99 | + | } | |
| 95 | 100 | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | |
| 96 | 101 | } | |
| 97 | 102 | } | |
| @@ -169,6 +174,9 @@ type barState struct { | |||
| 169 | 174 | statusFeedback string | |
| 170 | 175 | mru []recentRouteDisplay | |
| 171 | 176 | rankedRecent []rankedRecent | |
| 177 | + | // showRanks prefixes each mru entry with its Ctrl+N rank while the | |
| 178 | + | // client holds Ctrl. | |
| 179 | + | showRanks bool | |
| 172 | 180 | attentionFrame int | |
| 173 | 181 | // theme is the client's terminal theme, if reported. Its zero value | |
| 174 | 182 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| @@ -304,6 +312,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 304 | 312 | state.rankedRecent = ranked | |
| 305 | 313 | } else if routeSnapshot.Generation != 0 { | |
| 306 | 314 | state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot)) | |
| 315 | + | state.showRanks = ac != nil && ac.ctrlHeld.Load() | |
| 307 | 316 | } | |
| 308 | 317 | return state | |
| 309 | 318 | } | |
| @@ -416,13 +425,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int { | |||
| 416 | 425 | return width | |
| 417 | 426 | } | |
| 418 | 427 | ||
| 419 | - | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T { | |
| 428 | + | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T { | |
| 420 | 429 | if budget <= 0 || len(entries) == 0 { | |
| 421 | 430 | return nil | |
| 422 | 431 | } | |
| 423 | 432 | used := 0 | |
| 424 | 433 | for i, entry := range entries { | |
| 425 | - | used += cost(entry) | |
| 434 | + | used += cost(i, entry) | |
| 426 | 435 | if used > budget { | |
| 427 | 436 | return entries[:i] | |
| 428 | 437 | } | |
| @@ -436,17 +445,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri | |||
| 436 | 445 | reserve = statusTextWidth(feedback) + 2 | |
| 437 | 446 | } | |
| 438 | 447 | budget := rowLen - leftUsed - reserve | |
| 439 | - | return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int { | |
| 448 | + | return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int { | |
| 440 | 449 | return recentRouteDisplayWidth(recentRouteDisplay{ | |
| 441 | 450 | name: entry.name, | |
| 442 | 451 | kind: entry.kind, | |
| 443 | 452 | ephemeral: entry.ephemeral, | |
| 444 | 453 | attention: entry.attention, | |
| 445 | - | }) + statusTextWidth(strconv.Itoa(entry.rank)+":") | |
| 454 | + | }) + rankPrefixWidth(entry.rank) | |
| 446 | 455 | }) | |
| 447 | 456 | } | |
| 448 | 457 | ||
| 449 | - | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay { | |
| 458 | + | func rankPrefixWidth(rank int) int { | |
| 459 | + | return statusTextWidth(strconv.Itoa(rank) + ":") | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix | |
| 463 | + | // of each entry Ctrl+1..9 reaches to its width. | |
| 464 | + | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay { | |
| 465 | + | cost := func(i int, entry recentRouteDisplay) int { | |
| 466 | + | width := recentRouteDisplayWidth(entry) | |
| 467 | + | if ranked && i < maxMRUSessions { | |
| 468 | + | width += rankPrefixWidth(i + 1) | |
| 469 | + | } | |
| 470 | + | return width | |
| 471 | + | } | |
| 450 | 472 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 451 | 473 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| 452 | 474 | copyReserve := 1 | |
| @@ -463,11 +485,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) | |||
| 463 | 485 | budget -= mruFutureRightReserve(rowLen) | |
| 464 | 486 | // Keep at least one recent session when it physically fits; the reserved | |
| 465 | 487 | // right side is only a budget preference, not a reason to hide all recents. | |
| 466 | - | if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost { | |
| 488 | + | if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost { | |
| 467 | 489 | budget = firstCost | |
| 468 | 490 | } | |
| 469 | 491 | } | |
| 470 | - | return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth) | |
| 492 | + | return fitWholeRecentEntries(entries, budget, cost) | |
| 471 | 493 | } | |
| 472 | 494 | ||
| 473 | 495 | const ( | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | const ( | 18 | const ( | ||
| 19 | + | // maxMRUSessions is how many history entries Ctrl+1..9 reach. | |||
| 19 | maxMRUSessions = 9 | 20 | maxMRUSessions = 9 | ||
| 20 | pulseFrameCount = 30 | 21 | pulseFrameCount = 30 | ||
| 21 | pulseFrameInterval = 120 * time.Millisecond | 22 | pulseFrameInterval = 120 * time.Millisecond | ||
| @@ -89,9 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||||
| 89 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | 90 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | ||
| 90 | } | 91 | } | ||
| 91 | } else { | 92 | } else { | ||
| 92 | - | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 93 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks) |
| 93 | for i, sess := range fittedMRU { | 94 | for i, sess := range fittedMRU { | ||
| 94 | style := styles.MRUStyle(i, len(fittedMRU)) | 95 | style := styles.MRUStyle(i, len(fittedMRU)) | ||
| 96 | + | if state.showRanks && i < maxMRUSessions { | |||
| 97 | + | drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced) | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 95 | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | 100 | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced) | ||
| 96 | } | 101 | } | ||
| 97 | } | 102 | } | ||
| @@ -169,6 +174,9 @@ type barState struct { | |||||
| 169 | statusFeedback string | 174 | statusFeedback string | ||
| 170 | mru []recentRouteDisplay | 175 | mru []recentRouteDisplay | ||
| 171 | rankedRecent []rankedRecent | 176 | rankedRecent []rankedRecent | ||
| 177 | + | // showRanks prefixes each mru entry with its Ctrl+N rank while the | |||
| 178 | + | // client holds Ctrl. | |||
| 179 | + | showRanks bool | |||
| 172 | attentionFrame int | 180 | attentionFrame int | ||
| 173 | // theme is the client's terminal theme, if reported. Its zero value | 181 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 174 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 182 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| @@ -304,6 +312,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||||
| 304 | state.rankedRecent = ranked | 312 | state.rankedRecent = ranked | ||
| 305 | } else if routeSnapshot.Generation != 0 { | 313 | } else if routeSnapshot.Generation != 0 { | ||
| 306 | state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot)) | 314 | state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot)) | ||
| 315 | + | state.showRanks = ac != nil && ac.ctrlHeld.Load() | |||
| 307 | } | 316 | } | ||
| 308 | return state | 317 | return state | ||
| 309 | } | 318 | } | ||
| @@ -416,13 +425,13 @@ func recentRouteDisplayWidth(entry recentRouteDisplay) int { | |||||
| 416 | return width | 425 | return width | ||
| 417 | } | 426 | } | ||
| 418 | 427 | ||||
| 419 | - | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T { | 428 | + | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T { |
| 420 | if budget <= 0 || len(entries) == 0 { | 429 | if budget <= 0 || len(entries) == 0 { | ||
| 421 | return nil | 430 | return nil | ||
| 422 | } | 431 | } | ||
| 423 | used := 0 | 432 | used := 0 | ||
| 424 | for i, entry := range entries { | 433 | for i, entry := range entries { | ||
| 425 | - | used += cost(entry) | 434 | + | used += cost(i, entry) |
| 426 | if used > budget { | 435 | if used > budget { | ||
| 427 | return entries[:i] | 436 | return entries[:i] | ||
| 428 | } | 437 | } | ||
| @@ -436,17 +445,30 @@ func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback stri | |||||
| 436 | reserve = statusTextWidth(feedback) + 2 | 445 | reserve = statusTextWidth(feedback) + 2 | ||
| 437 | } | 446 | } | ||
| 438 | budget := rowLen - leftUsed - reserve | 447 | budget := rowLen - leftUsed - reserve | ||
| 439 | - | return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int { | 448 | + | return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int { |
| 440 | return recentRouteDisplayWidth(recentRouteDisplay{ | 449 | return recentRouteDisplayWidth(recentRouteDisplay{ | ||
| 441 | name: entry.name, | 450 | name: entry.name, | ||
| 442 | kind: entry.kind, | 451 | kind: entry.kind, | ||
| 443 | ephemeral: entry.ephemeral, | 452 | ephemeral: entry.ephemeral, | ||
| 444 | attention: entry.attention, | 453 | attention: entry.attention, | ||
| 445 | - | }) + statusTextWidth(strconv.Itoa(entry.rank)+":") | 454 | + | }) + rankPrefixWidth(entry.rank) |
| 446 | }) | 455 | }) | ||
| 447 | } | 456 | } | ||
| 448 | 457 | ||||
| 449 | - | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay { | 458 | + | func rankPrefixWidth(rank int) int { |
| 459 | + | return statusTextWidth(strconv.Itoa(rank) + ":") | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | // fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix | |||
| 463 | + | // of each entry Ctrl+1..9 reaches to its width. | |||
| 464 | + | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay { | |||
| 465 | + | cost := func(i int, entry recentRouteDisplay) int { | |||
| 466 | + | width := recentRouteDisplayWidth(entry) | |||
| 467 | + | if ranked && i < maxMRUSessions { | |||
| 468 | + | width += rankPrefixWidth(i + 1) | |||
| 469 | + | } | |||
| 470 | + | return width | |||
| 471 | + | } | |||
| 450 | // With no feedback, keep one blank trailing cell; with feedback, reserve | 472 | // With no feedback, keep one blank trailing cell; with feedback, reserve | ||
| 451 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 473 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
| 452 | copyReserve := 1 | 474 | copyReserve := 1 | ||
| @@ -463,11 +485,11 @@ func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) | |||||
| 463 | budget -= mruFutureRightReserve(rowLen) | 485 | budget -= mruFutureRightReserve(rowLen) | ||
| 464 | // Keep at least one recent session when it physically fits; the reserved | 486 | // Keep at least one recent session when it physically fits; the reserved | ||
| 465 | // right side is only a budget preference, not a reason to hide all recents. | 487 | // right side is only a budget preference, not a reason to hide all recents. | ||
| 466 | - | if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost { | 488 | + | if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost { |
| 467 | budget = firstCost | 489 | budget = firstCost | ||
| 468 | } | 490 | } | ||
| 469 | } | 491 | } | ||
| 470 | - | return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth) | 492 | + | return fitWholeRecentEntries(entries, budget, cost) |
| 471 | } | 493 | } | ||
| 472 | 494 | ||||
| 473 | const ( | 495 | const ( | ||
M internal/usecase/daemon/status_test.go +34 -0
| @@ -1090,6 +1090,40 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||
| 1090 | 1090 | t.Fatalf("inline bell not rendered: %q", rowText(row)) | |
| 1091 | 1091 | } | |
| 1092 | 1092 | ||
| 1093 | + | func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) { | |
| 1094 | + | mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}} | |
| 1095 | + | var tenEntries []recentRouteDisplay | |
| 1096 | + | for _, name := range "abcdefghij" { | |
| 1097 | + | tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)}) | |
| 1098 | + | } | |
| 1099 | + | tests := []struct { | |
| 1100 | + | name string | |
| 1101 | + | mru []recentRouteDisplay | |
| 1102 | + | showRanks bool | |
| 1103 | + | width int | |
| 1104 | + | want string | |
| 1105 | + | }{ | |
| 1106 | + | {name: "names only", width: 44, want: " cur local neferwl@arch old "}, | |
| 1107 | + | {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "}, | |
| 1108 | + | {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "}, | |
| 1109 | + | {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "}, | |
| 1110 | + | } | |
| 1111 | + | for _, tt := range tests { | |
| 1112 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1113 | + | entries := mru | |
| 1114 | + | if tt.mru != nil { | |
| 1115 | + | entries = tt.mru | |
| 1116 | + | } | |
| 1117 | + | state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks} | |
| 1118 | + | row := make([]renderer.Cell, tt.width) | |
| 1119 | + | ||
| 1120 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1121 | + | ||
| 1122 | + | require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ") | |
| 1123 | + | }) | |
| 1124 | + | } | |
| 1125 | + | } | |
| 1126 | + | ||
| 1093 | 1127 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |
| 1094 | 1128 | 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} | |
| 1095 | 1129 | styles := themeui.NewStyles(theme) | |
| @@ -1090,6 +1090,40 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||||
| 1090 | t.Fatalf("inline bell not rendered: %q", rowText(row)) | 1090 | t.Fatalf("inline bell not rendered: %q", rowText(row)) | ||
| 1091 | } | 1091 | } | ||
| 1092 | 1092 | ||||
| 1093 | + | func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) { | |||
| 1094 | + | mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}} | |||
| 1095 | + | var tenEntries []recentRouteDisplay | |||
| 1096 | + | for _, name := range "abcdefghij" { | |||
| 1097 | + | tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)}) | |||
| 1098 | + | } | |||
| 1099 | + | tests := []struct { | |||
| 1100 | + | name string | |||
| 1101 | + | mru []recentRouteDisplay | |||
| 1102 | + | showRanks bool | |||
| 1103 | + | width int | |||
| 1104 | + | want string | |||
| 1105 | + | }{ | |||
| 1106 | + | {name: "names only", width: 44, want: " cur local neferwl@arch old "}, | |||
| 1107 | + | {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "}, | |||
| 1108 | + | {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "}, | |||
| 1109 | + | {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "}, | |||
| 1110 | + | } | |||
| 1111 | + | for _, tt := range tests { | |||
| 1112 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1113 | + | entries := mru | |||
| 1114 | + | if tt.mru != nil { | |||
| 1115 | + | entries = tt.mru | |||
| 1116 | + | } | |||
| 1117 | + | state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks} | |||
| 1118 | + | row := make([]renderer.Cell, tt.width) | |||
| 1119 | + | ||||
| 1120 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |||
| 1121 | + | ||||
| 1122 | + | require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ") | |||
| 1123 | + | }) | |||
| 1124 | + | } | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1093 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | 1127 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | ||
| 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} | 1128 | 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} | ||
| 1095 | styles := themeui.NewStyles(theme) | 1129 | styles := themeui.NewStyles(theme) | ||
M internal/usecase/keys/kittykey/kittykey.go +74 -27
| @@ -25,8 +25,12 @@ const ( | |||
| 25 | 25 | FlagAllKeys = 1 << 3 | |
| 26 | 26 | FlagText = 1 << 4 | |
| 27 | 27 | ||
| 28 | - | // OuterFlags are the flags vev requests from the outer terminal. | |
| 28 | + | // OuterFlags is the key encoding the rest of vev consumes. A Normalizer | |
| 29 | + | // rewrites the richer RequestFlags stream into it. | |
| 29 | 30 | OuterFlags = FlagDisambiguate | FlagAlternateKeys | |
| 31 | + | // RequestFlags are the flags vev requests from the outer terminal: every | |
| 32 | + | // key, modifier presses and releases included, as an escape code. | |
| 33 | + | RequestFlags = OuterFlags | FlagReportEvents | FlagAllKeys | FlagText | |
| 30 | 34 | ) | |
| 31 | 35 | ||
| 32 | 36 | // Modifier bits, as encoded in the mods parameter minus one. | |
| @@ -101,61 +105,104 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) { | |||
| 101 | 105 | if b != 'u' || i == 2 { | |
| 102 | 106 | return Event{}, 0, false, false | |
| 103 | 107 | } | |
| 104 | - | ev, ok := parseParams(data[2:i]) | |
| 105 | - | return ev, i + 1, ok, false | |
| 108 | + | key, ok := parseParams(data[2:i]) | |
| 109 | + | if !ok || key.ev.Code == 0 { | |
| 110 | + | return Event{}, 0, false, false | |
| 111 | + | } | |
| 112 | + | return key.ev, i + 1, true, false | |
| 106 | 113 | } | |
| 107 | 114 | return Event{}, 0, false, i < maxSeq | |
| 108 | 115 | } | |
| 109 | 116 | ||
| 110 | - | func parseParams(params []byte) (Event, bool) { | |
| 117 | + | // fullKey is one CSI u key event with the fields Event leaves out. | |
| 118 | + | type fullKey struct { | |
| 119 | + | ev Event | |
| 120 | + | // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out. | |
| 121 | + | locks int | |
| 122 | + | // text holds the associated text (FlagText), control codes removed. | |
| 123 | + | text []rune | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func parseParams(params []byte) (fullKey, bool) { | |
| 111 | 127 | fields := splitByte(params, ';') | |
| 112 | 128 | if len(fields) > 3 { | |
| 113 | - | return Event{}, false | |
| 129 | + | return fullKey{}, false | |
| 114 | 130 | } | |
| 115 | 131 | keys := splitByte(fields[0], ':') | |
| 116 | 132 | if len(keys) > 3 { | |
| 117 | - | return Event{}, false | |
| 133 | + | return fullKey{}, false | |
| 118 | 134 | } | |
| 119 | 135 | code, ok := atoi(keys[0]) | |
| 120 | - | if !ok || code == 0 { | |
| 121 | - | return Event{}, false | |
| 136 | + | if !ok { | |
| 137 | + | return fullKey{}, false | |
| 122 | 138 | } | |
| 123 | - | ev := Event{Code: rune(code)} | |
| 139 | + | key := fullKey{} | |
| 140 | + | ev := &key.ev | |
| 141 | + | ev.Code = rune(code) | |
| 124 | 142 | if len(keys) > 1 && len(keys[1]) > 0 { | |
| 125 | 143 | v, ok := atoi(keys[1]) | |
| 126 | 144 | if !ok { | |
| 127 | - | return Event{}, false | |
| 145 | + | return fullKey{}, false | |
| 128 | 146 | } | |
| 129 | 147 | ev.Shifted = rune(v) | |
| 130 | 148 | } | |
| 131 | 149 | if len(keys) > 2 && len(keys[2]) > 0 { | |
| 132 | 150 | v, ok := atoi(keys[2]) | |
| 133 | 151 | if !ok { | |
| 134 | - | return Event{}, false | |
| 152 | + | return fullKey{}, false | |
| 135 | 153 | } | |
| 136 | 154 | ev.Base = rune(v) | |
| 137 | 155 | } | |
| 138 | 156 | if len(fields) > 1 && len(fields[1]) > 0 { | |
| 139 | - | sub := splitByte(fields[1], ':') | |
| 140 | - | if len(sub) > 2 { | |
| 141 | - | return Event{}, false | |
| 142 | - | } | |
| 143 | - | mods, ok := atoi(sub[0]) | |
| 144 | - | if !ok || mods < 1 { | |
| 145 | - | return Event{}, false | |
| 157 | + | mods, event, ok := parseMods(fields[1]) | |
| 158 | + | if !ok { | |
| 159 | + | return fullKey{}, false | |
| 146 | 160 | } | |
| 147 | - | ev.Mods = (mods - 1) &^ modLocks | |
| 148 | - | if len(sub) == 2 && len(sub[1]) > 0 { | |
| 149 | - | event, ok := atoi(sub[1]) | |
| 150 | - | if !ok || event < 1 || event > 3 { | |
| 151 | - | return Event{}, false | |
| 161 | + | ev.Mods = mods &^ modLocks | |
| 162 | + | key.locks = mods & modLocks | |
| 163 | + | ev.Release = event == eventRelease | |
| 164 | + | } | |
| 165 | + | // A third field carries associated text (FlagText). Only a Normalizer | |
| 166 | + | // needs it; re-encoding a key never does. | |
| 167 | + | if len(fields) > 2 && len(fields[2]) > 0 { | |
| 168 | + | for _, field := range splitByte(fields[2], ':') { | |
| 169 | + | v, ok := atoi(field) | |
| 170 | + | if !ok { | |
| 171 | + | return fullKey{}, false | |
| 152 | 172 | } | |
| 153 | - | ev.Release = event == 3 | |
| 173 | + | if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) { | |
| 174 | + | key.text = append(key.text, r) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return key, true | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Event types; a repeat (2) is handled as a press. | |
| 182 | + | const ( | |
| 183 | + | eventPress = 1 | |
| 184 | + | eventRelease = 3 | |
| 185 | + | ) | |
| 186 | + | ||
| 187 | + | // parseMods decodes a "mods[:event]" field: the modifier bits, lock bits | |
| 188 | + | // included, and the event type (press when absent). | |
| 189 | + | func parseMods(field []byte) (mods, event int, ok bool) { | |
| 190 | + | sub := splitByte(field, ':') | |
| 191 | + | if len(sub) > 2 { | |
| 192 | + | return 0, 0, false | |
| 193 | + | } | |
| 194 | + | encoded, ok := atoi(sub[0]) | |
| 195 | + | if !ok || encoded < 1 { | |
| 196 | + | return 0, 0, false | |
| 197 | + | } | |
| 198 | + | event = eventPress | |
| 199 | + | if len(sub) == 2 && len(sub[1]) > 0 { | |
| 200 | + | event, ok = atoi(sub[1]) | |
| 201 | + | if !ok || event < eventPress || event > eventRelease { | |
| 202 | + | return 0, 0, false | |
| 154 | 203 | } | |
| 155 | 204 | } | |
| 156 | - | // A third field carries associated text (FlagText); it is not needed to | |
| 157 | - | // re-encode the key and is intentionally ignored. | |
| 158 | - | return ev, true | |
| 205 | + | return encoded - 1, event, true | |
| 159 | 206 | } | |
| 160 | 207 | ||
| 161 | 208 | // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in | |
| @@ -25,8 +25,12 @@ const ( | |||||
| 25 | FlagAllKeys = 1 << 3 | 25 | FlagAllKeys = 1 << 3 | ||
| 26 | FlagText = 1 << 4 | 26 | FlagText = 1 << 4 | ||
| 27 | 27 | ||||
| 28 | - | // OuterFlags are the flags vev requests from the outer terminal. | 28 | + | // OuterFlags is the key encoding the rest of vev consumes. A Normalizer |
| 29 | + | // rewrites the richer RequestFlags stream into it. | |||
| 29 | OuterFlags = FlagDisambiguate | FlagAlternateKeys | 30 | OuterFlags = FlagDisambiguate | FlagAlternateKeys | ||
| 31 | + | // RequestFlags are the flags vev requests from the outer terminal: every | |||
| 32 | + | // key, modifier presses and releases included, as an escape code. | |||
| 33 | + | RequestFlags = OuterFlags | FlagReportEvents | FlagAllKeys | FlagText | |||
| 30 | ) | 34 | ) | ||
| 31 | 35 | ||||
| 32 | // Modifier bits, as encoded in the mods parameter minus one. | 36 | // Modifier bits, as encoded in the mods parameter minus one. | ||
| @@ -101,61 +105,104 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) { | |||||
| 101 | if b != 'u' || i == 2 { | 105 | if b != 'u' || i == 2 { | ||
| 102 | return Event{}, 0, false, false | 106 | return Event{}, 0, false, false | ||
| 103 | } | 107 | } | ||
| 104 | - | ev, ok := parseParams(data[2:i]) | 108 | + | key, ok := parseParams(data[2:i]) |
| 105 | - | return ev, i + 1, ok, false | 109 | + | if !ok || key.ev.Code == 0 { |
| 110 | + | return Event{}, 0, false, false | |||
| 111 | + | } | |||
| 112 | + | return key.ev, i + 1, true, false | |||
| 106 | } | 113 | } | ||
| 107 | return Event{}, 0, false, i < maxSeq | 114 | return Event{}, 0, false, i < maxSeq | ||
| 108 | } | 115 | } | ||
| 109 | 116 | ||||
| 110 | - | func parseParams(params []byte) (Event, bool) { | 117 | + | // fullKey is one CSI u key event with the fields Event leaves out. |
| 118 | + | type fullKey struct { | |||
| 119 | + | ev Event | |||
| 120 | + | // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out. | |||
| 121 | + | locks int | |||
| 122 | + | // text holds the associated text (FlagText), control codes removed. | |||
| 123 | + | text []rune | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func parseParams(params []byte) (fullKey, bool) { | |||
| 111 | fields := splitByte(params, ';') | 127 | fields := splitByte(params, ';') | ||
| 112 | if len(fields) > 3 { | 128 | if len(fields) > 3 { | ||
| 113 | - | return Event{}, false | 129 | + | return fullKey{}, false |
| 114 | } | 130 | } | ||
| 115 | keys := splitByte(fields[0], ':') | 131 | keys := splitByte(fields[0], ':') | ||
| 116 | if len(keys) > 3 { | 132 | if len(keys) > 3 { | ||
| 117 | - | return Event{}, false | 133 | + | return fullKey{}, false |
| 118 | } | 134 | } | ||
| 119 | code, ok := atoi(keys[0]) | 135 | code, ok := atoi(keys[0]) | ||
| 120 | - | if !ok || code == 0 { | 136 | + | if !ok { |
| 121 | - | return Event{}, false | 137 | + | return fullKey{}, false |
| 122 | } | 138 | } | ||
| 123 | - | ev := Event{Code: rune(code)} | 139 | + | key := fullKey{} |
| 140 | + | ev := &key.ev | |||
| 141 | + | ev.Code = rune(code) | |||
| 124 | if len(keys) > 1 && len(keys[1]) > 0 { | 142 | if len(keys) > 1 && len(keys[1]) > 0 { | ||
| 125 | v, ok := atoi(keys[1]) | 143 | v, ok := atoi(keys[1]) | ||
| 126 | if !ok { | 144 | if !ok { | ||
| 127 | - | return Event{}, false | 145 | + | return fullKey{}, false |
| 128 | } | 146 | } | ||
| 129 | ev.Shifted = rune(v) | 147 | ev.Shifted = rune(v) | ||
| 130 | } | 148 | } | ||
| 131 | if len(keys) > 2 && len(keys[2]) > 0 { | 149 | if len(keys) > 2 && len(keys[2]) > 0 { | ||
| 132 | v, ok := atoi(keys[2]) | 150 | v, ok := atoi(keys[2]) | ||
| 133 | if !ok { | 151 | if !ok { | ||
| 134 | - | return Event{}, false | 152 | + | return fullKey{}, false |
| 135 | } | 153 | } | ||
| 136 | ev.Base = rune(v) | 154 | ev.Base = rune(v) | ||
| 137 | } | 155 | } | ||
| 138 | if len(fields) > 1 && len(fields[1]) > 0 { | 156 | if len(fields) > 1 && len(fields[1]) > 0 { | ||
| 139 | - | sub := splitByte(fields[1], ':') | 157 | + | mods, event, ok := parseMods(fields[1]) |
| 140 | - | if len(sub) > 2 { | 158 | + | if !ok { |
| 141 | - | return Event{}, false | 159 | + | return fullKey{}, false |
| 142 | - | } | |||
| 143 | - | mods, ok := atoi(sub[0]) | |||
| 144 | - | if !ok || mods < 1 { | |||
| 145 | - | return Event{}, false | |||
| 146 | } | 160 | } | ||
| 147 | - | ev.Mods = (mods - 1) &^ modLocks | 161 | + | ev.Mods = mods &^ modLocks |
| 148 | - | if len(sub) == 2 && len(sub[1]) > 0 { | 162 | + | key.locks = mods & modLocks |
| 149 | - | event, ok := atoi(sub[1]) | 163 | + | ev.Release = event == eventRelease |
| 150 | - | if !ok || event < 1 || event > 3 { | 164 | + | } |
| 151 | - | return Event{}, false | 165 | + | // A third field carries associated text (FlagText). Only a Normalizer |
| 166 | + | // needs it; re-encoding a key never does. | |||
| 167 | + | if len(fields) > 2 && len(fields[2]) > 0 { | |||
| 168 | + | for _, field := range splitByte(fields[2], ':') { | |||
| 169 | + | v, ok := atoi(field) | |||
| 170 | + | if !ok { | |||
| 171 | + | return fullKey{}, false | |||
| 152 | } | 172 | } | ||
| 153 | - | ev.Release = event == 3 | 173 | + | if r := rune(v); r >= 0x20 && (r < 0x7f || r > 0x9f) && utf8.ValidRune(r) { |
| 174 | + | key.text = append(key.text, r) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | return key, true | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Event types; a repeat (2) is handled as a press. | |||
| 182 | + | const ( | |||
| 183 | + | eventPress = 1 | |||
| 184 | + | eventRelease = 3 | |||
| 185 | + | ) | |||
| 186 | + | ||||
| 187 | + | // parseMods decodes a "mods[:event]" field: the modifier bits, lock bits | |||
| 188 | + | // included, and the event type (press when absent). | |||
| 189 | + | func parseMods(field []byte) (mods, event int, ok bool) { | |||
| 190 | + | sub := splitByte(field, ':') | |||
| 191 | + | if len(sub) > 2 { | |||
| 192 | + | return 0, 0, false | |||
| 193 | + | } | |||
| 194 | + | encoded, ok := atoi(sub[0]) | |||
| 195 | + | if !ok || encoded < 1 { | |||
| 196 | + | return 0, 0, false | |||
| 197 | + | } | |||
| 198 | + | event = eventPress | |||
| 199 | + | if len(sub) == 2 && len(sub[1]) > 0 { | |||
| 200 | + | event, ok = atoi(sub[1]) | |||
| 201 | + | if !ok || event < eventPress || event > eventRelease { | |||
| 202 | + | return 0, 0, false | |||
| 154 | } | 203 | } | ||
| 155 | } | 204 | } | ||
| 156 | - | // A third field carries associated text (FlagText); it is not needed to | 205 | + | return encoded - 1, event, true |
| 157 | - | // re-encode the key and is intentionally ignored. | |||
| 158 | - | return ev, true | |||
| 159 | } | 206 | } | ||
| 160 | 207 | ||||
| 161 | // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in | 208 | // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in | ||
A internal/usecase/keys/kittykey/normalize.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package kittykey | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // maxNormalizedSeq bounds one held key sequence. Associated text makes a key | |
| 10 | + | // event as long as the text an IME commits at once. | |
| 11 | + | const maxNormalizedSeq = 4096 | |
| 12 | + | ||
| 13 | + | // Kitty functional key codes of the modifier and lock keys. RequestFlags | |
| 14 | + | // reports them; OuterFlags never does. | |
| 15 | + | const ( | |
| 16 | + | keyCapsLock = 57358 | |
| 17 | + | keyNumLock = 57360 | |
| 18 | + | keyLeftShift = 57441 | |
| 19 | + | keyLeftControl = 57442 | |
| 20 | + | keyRightControl = 57448 | |
| 21 | + | keyISOLevel5 = 57454 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // Normalizer rewrites the outer terminal's RequestFlags key stream into the | |
| 25 | + | // OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is | |
| 26 | + | // physically held. Release events, modifier and lock key events are dropped, | |
| 27 | + | // repeats become presses, and text keys become their text again. | |
| 28 | + | // | |
| 29 | + | // Input already in the OuterFlags or legacy encoding passes through | |
| 30 | + | // unchanged, so enabling a Normalizer before the terminal switches flags is | |
| 31 | + | // safe. A trailing incomplete escape sequence is held until the next call. A | |
| 32 | + | // lone trailing ESC is held only once a CSI u key event proved the terminal | |
| 33 | + | // reports Escape as CSI 27 u; before that it may be a real Escape press. | |
| 34 | + | // A Normalizer is not safe for concurrent use. | |
| 35 | + | type Normalizer struct { | |
| 36 | + | pending []byte | |
| 37 | + | paste bool | |
| 38 | + | ctrl bool | |
| 39 | + | // kitty records that a CSI u key event arrived, so the terminal encodes | |
| 40 | + | // Escape itself as one and a lone ESC can only start a split sequence. | |
| 41 | + | kitty bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no | |
| 45 | + | // other key but a top-row digit was pressed since. Only a Ctrl key press sets | |
| 46 | + | // it, so a terminal that reports no modifier key events never shows it held. | |
| 47 | + | func (n *Normalizer) CtrlHeld() bool { return n.ctrl } | |
| 48 | + | ||
| 49 | + | // ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may | |
| 50 | + | // never report the release. | |
| 51 | + | func (n *Normalizer) ReleaseCtrl() { n.ctrl = false } | |
| 52 | + | ||
| 53 | + | // Normalize returns data in the OuterFlags encoding, in a new slice. | |
| 54 | + | func (n *Normalizer) Normalize(data []byte) []byte { | |
| 55 | + | if len(n.pending) > 0 { | |
| 56 | + | data = append(n.pending, data...) | |
| 57 | + | n.pending = nil | |
| 58 | + | } | |
| 59 | + | out := make([]byte, 0, len(data)) | |
| 60 | + | for i := 0; i < len(data); { | |
| 61 | + | if n.paste { | |
| 62 | + | end := bytes.Index(data[i:], pasteClose) | |
| 63 | + | if end < 0 { | |
| 64 | + | keep := len(data) - partialSuffix(data[i:], pasteClose) | |
| 65 | + | out = append(out, data[i:keep]...) | |
| 66 | + | n.hold(data[keep:]) | |
| 67 | + | return out | |
| 68 | + | } | |
| 69 | + | end += i + len(pasteClose) | |
| 70 | + | out = append(out, data[i:end]...) | |
| 71 | + | i = end | |
| 72 | + | n.paste = false | |
| 73 | + | continue | |
| 74 | + | } | |
| 75 | + | if data[i] != 0x1b { | |
| 76 | + | out = append(out, data[i]) | |
| 77 | + | i++ | |
| 78 | + | continue | |
| 79 | + | } | |
| 80 | + | rest := data[i:] | |
| 81 | + | if bytes.HasPrefix(rest, pasteOpen) { | |
| 82 | + | out = append(out, pasteOpen...) | |
| 83 | + | i += len(pasteOpen) | |
| 84 | + | n.paste = true | |
| 85 | + | continue | |
| 86 | + | } | |
| 87 | + | params, final, size, partial := scanCSI(rest) | |
| 88 | + | if partial && len(rest) == 1 && !n.kitty { | |
| 89 | + | out = append(out, rest...) | |
| 90 | + | return out | |
| 91 | + | } | |
| 92 | + | if partial { | |
| 93 | + | n.hold(rest) | |
| 94 | + | return out | |
| 95 | + | } | |
| 96 | + | if size > 0 { | |
| 97 | + | if repl, ok := n.rewrite(params, final); ok { | |
| 98 | + | out = append(out, repl...) | |
| 99 | + | i += size | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | out = append(out, data[i]) | |
| 104 | + | i++ | |
| 105 | + | } | |
| 106 | + | return out | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (n *Normalizer) hold(tail []byte) { | |
| 110 | + | if len(tail) > 0 { | |
| 111 | + | n.pending = append([]byte(nil), tail...) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // partialSuffix returns the length of the longest suffix of data that is a | |
| 116 | + | // strict prefix of marker. | |
| 117 | + | func partialSuffix(data, marker []byte) int { | |
| 118 | + | for k := min(len(data), len(marker)-1); k > 0; k-- { | |
| 119 | + | if bytes.HasPrefix(marker, data[len(data)-k:]) { | |
| 120 | + | return k | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return 0 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // scanCSI reads ESC [ params final at the start of data, where params holds | |
| 127 | + | // only digits, ';' and ':'. size is 0 when data starts no such sequence; | |
| 128 | + | // partial reports a strict prefix that may still complete. | |
| 129 | + | func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) { | |
| 130 | + | if len(data) == 1 { | |
| 131 | + | return nil, 0, 0, true | |
| 132 | + | } | |
| 133 | + | if data[1] != '[' { | |
| 134 | + | return nil, 0, 0, false | |
| 135 | + | } | |
| 136 | + | for i := 2; i < len(data) && i < maxNormalizedSeq; i++ { | |
| 137 | + | b := data[i] | |
| 138 | + | if (b >= '0' && b <= '9') || b == ';' || b == ':' { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | return data[2:i], b, i + 1, false | |
| 142 | + | } | |
| 143 | + | return nil, 0, 0, len(data) < maxNormalizedSeq | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // rewrite returns the OuterFlags form of one key sequence, or false when the | |
| 147 | + | // sequence is not a key event this package owns. | |
| 148 | + | func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) { | |
| 149 | + | switch final { | |
| 150 | + | case 'u': | |
| 151 | + | if len(params) == 0 { | |
| 152 | + | return nil, false | |
| 153 | + | } | |
| 154 | + | key, ok := parseParams(params) | |
| 155 | + | if !ok { | |
| 156 | + | return nil, false | |
| 157 | + | } | |
| 158 | + | n.kitty = true | |
| 159 | + | n.track(key.ev, true) | |
| 160 | + | return encodeOuter(key), true | |
| 161 | + | case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S': | |
| 162 | + | return n.rewriteFunctional(params, final) | |
| 163 | + | } | |
| 164 | + | return nil, false | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event] | |
| 168 | + | // letter forms. Forms without a modifier field are already in OuterFlags | |
| 169 | + | // form, which also keeps bracketed paste markers and replies untouched. | |
| 170 | + | func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) { | |
| 171 | + | fields := splitByte(params, ';') | |
| 172 | + | if len(fields) != 2 { | |
| 173 | + | return nil, false | |
| 174 | + | } | |
| 175 | + | mods, event, ok := parseMods(fields[1]) | |
| 176 | + | if !ok { | |
| 177 | + | return nil, false | |
| 178 | + | } | |
| 179 | + | n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false) | |
| 180 | + | if event == eventRelease { | |
| 181 | + | return nil, true | |
| 182 | + | } | |
| 183 | + | // Functional keys keep their lock bits under OuterFlags. | |
| 184 | + | out := []byte("\x1b[") | |
| 185 | + | if mods != 0 || (final == '~' || string(fields[0]) != "1") { | |
| 186 | + | out = append(out, fields[0]...) | |
| 187 | + | } | |
| 188 | + | if mods != 0 { | |
| 189 | + | out = append(out, ';') | |
| 190 | + | out = strconv.AppendInt(out, int64(mods+1), 10) | |
| 191 | + | } | |
| 192 | + | return append(out, final), true | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // track updates the Ctrl state from one key event. A Ctrl key event reports | |
| 196 | + | // the Ctrl state after it. Pressing any other key ends a Ctrl held on its own, | |
| 197 | + | // except a top-row digit, the Ctrl+1..9 recent-session switch. | |
| 198 | + | func (n *Normalizer) track(ev Event, unicodeKey bool) { | |
| 199 | + | switch { | |
| 200 | + | case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl): | |
| 201 | + | n.ctrl = ev.Mods&ModCtrl != 0 | |
| 202 | + | case unicodeKey && ev.Code == 0: | |
| 203 | + | // A text-only event carries no key. | |
| 204 | + | case unicodeKey && isModifierOrLock(ev.Code): | |
| 205 | + | if ev.Mods&ModCtrl == 0 { | |
| 206 | + | n.ctrl = false | |
| 207 | + | } | |
| 208 | + | case ev.Mods&ModCtrl == 0: | |
| 209 | + | n.ctrl = false | |
| 210 | + | case ev.Release: | |
| 211 | + | case unicodeKey: | |
| 212 | + | if _, digit := ev.Digit(); !digit { | |
| 213 | + | n.ctrl = false | |
| 214 | + | } | |
| 215 | + | default: | |
| 216 | + | n.ctrl = false | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // encodeOuter re-encodes one RequestFlags key event as the terminal would | |
| 221 | + | // have sent it under OuterFlags, or nil when it would have sent nothing. | |
| 222 | + | func encodeOuter(key fullKey) []byte { | |
| 223 | + | ev := key.ev | |
| 224 | + | if ev.Release || isModifierOrLock(ev.Code) { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | switch { | |
| 228 | + | case len(key.text) > 0 || ev.Code == 0: | |
| 229 | + | // A terminal sends the text a key generates as is, whatever the | |
| 230 | + | // modifiers that produced it. | |
| 231 | + | return appendRunes(nil, key.text) | |
| 232 | + | case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace): | |
| 233 | + | return []byte{byte(ev.Code)} | |
| 234 | + | case ev.Mods&^ModShift == 0 && isTextKey(ev.Code): | |
| 235 | + | return legacyText(ev) | |
| 236 | + | } | |
| 237 | + | // A key sent as an escape code reports the lock bits too. | |
| 238 | + | ev.Mods |= key.locks | |
| 239 | + | return encodeCSIu(ev, OuterFlags) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func isModifierOrLock(code rune) bool { | |
| 243 | + | return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // isTextKey reports a key whose unmodified press produces its own text. | |
| 247 | + | func isTextKey(code rune) bool { | |
| 248 | + | return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func appendRunes(out []byte, runes []rune) []byte { | |
| 252 | + | for _, r := range runes { | |
| 253 | + | out = utf8.AppendRune(out, r) | |
| 254 | + | } | |
| 255 | + | return out | |
| 256 | + | } | |
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | package kittykey | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // maxNormalizedSeq bounds one held key sequence. Associated text makes a key | |||
| 10 | + | // event as long as the text an IME commits at once. | |||
| 11 | + | const maxNormalizedSeq = 4096 | |||
| 12 | + | ||||
| 13 | + | // Kitty functional key codes of the modifier and lock keys. RequestFlags | |||
| 14 | + | // reports them; OuterFlags never does. | |||
| 15 | + | const ( | |||
| 16 | + | keyCapsLock = 57358 | |||
| 17 | + | keyNumLock = 57360 | |||
| 18 | + | keyLeftShift = 57441 | |||
| 19 | + | keyLeftControl = 57442 | |||
| 20 | + | keyRightControl = 57448 | |||
| 21 | + | keyISOLevel5 = 57454 | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // Normalizer rewrites the outer terminal's RequestFlags key stream into the | |||
| 25 | + | // OuterFlags encoding the rest of vev consumes, and tracks whether Ctrl is | |||
| 26 | + | // physically held. Release events, modifier and lock key events are dropped, | |||
| 27 | + | // repeats become presses, and text keys become their text again. | |||
| 28 | + | // | |||
| 29 | + | // Input already in the OuterFlags or legacy encoding passes through | |||
| 30 | + | // unchanged, so enabling a Normalizer before the terminal switches flags is | |||
| 31 | + | // safe. A trailing incomplete escape sequence is held until the next call. A | |||
| 32 | + | // lone trailing ESC is held only once a CSI u key event proved the terminal | |||
| 33 | + | // reports Escape as CSI 27 u; before that it may be a real Escape press. | |||
| 34 | + | // A Normalizer is not safe for concurrent use. | |||
| 35 | + | type Normalizer struct { | |||
| 36 | + | pending []byte | |||
| 37 | + | paste bool | |||
| 38 | + | ctrl bool | |||
| 39 | + | // kitty records that a CSI u key event arrived, so the terminal encodes | |||
| 40 | + | // Escape itself as one and a lone ESC can only start a split sequence. | |||
| 41 | + | kitty bool | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no | |||
| 45 | + | // other key but a top-row digit was pressed since. Only a Ctrl key press sets | |||
| 46 | + | // it, so a terminal that reports no modifier key events never shows it held. | |||
| 47 | + | func (n *Normalizer) CtrlHeld() bool { return n.ctrl } | |||
| 48 | + | ||||
| 49 | + | // ReleaseCtrl forgets a held Ctrl, for a terminal that lost focus and may | |||
| 50 | + | // never report the release. | |||
| 51 | + | func (n *Normalizer) ReleaseCtrl() { n.ctrl = false } | |||
| 52 | + | ||||
| 53 | + | // Normalize returns data in the OuterFlags encoding, in a new slice. | |||
| 54 | + | func (n *Normalizer) Normalize(data []byte) []byte { | |||
| 55 | + | if len(n.pending) > 0 { | |||
| 56 | + | data = append(n.pending, data...) | |||
| 57 | + | n.pending = nil | |||
| 58 | + | } | |||
| 59 | + | out := make([]byte, 0, len(data)) | |||
| 60 | + | for i := 0; i < len(data); { | |||
| 61 | + | if n.paste { | |||
| 62 | + | end := bytes.Index(data[i:], pasteClose) | |||
| 63 | + | if end < 0 { | |||
| 64 | + | keep := len(data) - partialSuffix(data[i:], pasteClose) | |||
| 65 | + | out = append(out, data[i:keep]...) | |||
| 66 | + | n.hold(data[keep:]) | |||
| 67 | + | return out | |||
| 68 | + | } | |||
| 69 | + | end += i + len(pasteClose) | |||
| 70 | + | out = append(out, data[i:end]...) | |||
| 71 | + | i = end | |||
| 72 | + | n.paste = false | |||
| 73 | + | continue | |||
| 74 | + | } | |||
| 75 | + | if data[i] != 0x1b { | |||
| 76 | + | out = append(out, data[i]) | |||
| 77 | + | i++ | |||
| 78 | + | continue | |||
| 79 | + | } | |||
| 80 | + | rest := data[i:] | |||
| 81 | + | if bytes.HasPrefix(rest, pasteOpen) { | |||
| 82 | + | out = append(out, pasteOpen...) | |||
| 83 | + | i += len(pasteOpen) | |||
| 84 | + | n.paste = true | |||
| 85 | + | continue | |||
| 86 | + | } | |||
| 87 | + | params, final, size, partial := scanCSI(rest) | |||
| 88 | + | if partial && len(rest) == 1 && !n.kitty { | |||
| 89 | + | out = append(out, rest...) | |||
| 90 | + | return out | |||
| 91 | + | } | |||
| 92 | + | if partial { | |||
| 93 | + | n.hold(rest) | |||
| 94 | + | return out | |||
| 95 | + | } | |||
| 96 | + | if size > 0 { | |||
| 97 | + | if repl, ok := n.rewrite(params, final); ok { | |||
| 98 | + | out = append(out, repl...) | |||
| 99 | + | i += size | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | out = append(out, data[i]) | |||
| 104 | + | i++ | |||
| 105 | + | } | |||
| 106 | + | return out | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (n *Normalizer) hold(tail []byte) { | |||
| 110 | + | if len(tail) > 0 { | |||
| 111 | + | n.pending = append([]byte(nil), tail...) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // partialSuffix returns the length of the longest suffix of data that is a | |||
| 116 | + | // strict prefix of marker. | |||
| 117 | + | func partialSuffix(data, marker []byte) int { | |||
| 118 | + | for k := min(len(data), len(marker)-1); k > 0; k-- { | |||
| 119 | + | if bytes.HasPrefix(marker, data[len(data)-k:]) { | |||
| 120 | + | return k | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return 0 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // scanCSI reads ESC [ params final at the start of data, where params holds | |||
| 127 | + | // only digits, ';' and ':'. size is 0 when data starts no such sequence; | |||
| 128 | + | // partial reports a strict prefix that may still complete. | |||
| 129 | + | func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) { | |||
| 130 | + | if len(data) == 1 { | |||
| 131 | + | return nil, 0, 0, true | |||
| 132 | + | } | |||
| 133 | + | if data[1] != '[' { | |||
| 134 | + | return nil, 0, 0, false | |||
| 135 | + | } | |||
| 136 | + | for i := 2; i < len(data) && i < maxNormalizedSeq; i++ { | |||
| 137 | + | b := data[i] | |||
| 138 | + | if (b >= '0' && b <= '9') || b == ';' || b == ':' { | |||
| 139 | + | continue | |||
| 140 | + | } | |||
| 141 | + | return data[2:i], b, i + 1, false | |||
| 142 | + | } | |||
| 143 | + | return nil, 0, 0, len(data) < maxNormalizedSeq | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // rewrite returns the OuterFlags form of one key sequence, or false when the | |||
| 147 | + | // sequence is not a key event this package owns. | |||
| 148 | + | func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) { | |||
| 149 | + | switch final { | |||
| 150 | + | case 'u': | |||
| 151 | + | if len(params) == 0 { | |||
| 152 | + | return nil, false | |||
| 153 | + | } | |||
| 154 | + | key, ok := parseParams(params) | |||
| 155 | + | if !ok { | |||
| 156 | + | return nil, false | |||
| 157 | + | } | |||
| 158 | + | n.kitty = true | |||
| 159 | + | n.track(key.ev, true) | |||
| 160 | + | return encodeOuter(key), true | |||
| 161 | + | case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S': | |||
| 162 | + | return n.rewriteFunctional(params, final) | |||
| 163 | + | } | |||
| 164 | + | return nil, false | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // rewriteFunctional handles CSI number;mods[:event] ~ and CSI 1;mods[:event] | |||
| 168 | + | // letter forms. Forms without a modifier field are already in OuterFlags | |||
| 169 | + | // form, which also keeps bracketed paste markers and replies untouched. | |||
| 170 | + | func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool) { | |||
| 171 | + | fields := splitByte(params, ';') | |||
| 172 | + | if len(fields) != 2 { | |||
| 173 | + | return nil, false | |||
| 174 | + | } | |||
| 175 | + | mods, event, ok := parseMods(fields[1]) | |||
| 176 | + | if !ok { | |||
| 177 | + | return nil, false | |||
| 178 | + | } | |||
| 179 | + | n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false) | |||
| 180 | + | if event == eventRelease { | |||
| 181 | + | return nil, true | |||
| 182 | + | } | |||
| 183 | + | // Functional keys keep their lock bits under OuterFlags. | |||
| 184 | + | out := []byte("\x1b[") | |||
| 185 | + | if mods != 0 || (final == '~' || string(fields[0]) != "1") { | |||
| 186 | + | out = append(out, fields[0]...) | |||
| 187 | + | } | |||
| 188 | + | if mods != 0 { | |||
| 189 | + | out = append(out, ';') | |||
| 190 | + | out = strconv.AppendInt(out, int64(mods+1), 10) | |||
| 191 | + | } | |||
| 192 | + | return append(out, final), true | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // track updates the Ctrl state from one key event. A Ctrl key event reports | |||
| 196 | + | // the Ctrl state after it. Pressing any other key ends a Ctrl held on its own, | |||
| 197 | + | // except a top-row digit, the Ctrl+1..9 recent-session switch. | |||
| 198 | + | func (n *Normalizer) track(ev Event, unicodeKey bool) { | |||
| 199 | + | switch { | |||
| 200 | + | case unicodeKey && (ev.Code == keyLeftControl || ev.Code == keyRightControl): | |||
| 201 | + | n.ctrl = ev.Mods&ModCtrl != 0 | |||
| 202 | + | case unicodeKey && ev.Code == 0: | |||
| 203 | + | // A text-only event carries no key. | |||
| 204 | + | case unicodeKey && isModifierOrLock(ev.Code): | |||
| 205 | + | if ev.Mods&ModCtrl == 0 { | |||
| 206 | + | n.ctrl = false | |||
| 207 | + | } | |||
| 208 | + | case ev.Mods&ModCtrl == 0: | |||
| 209 | + | n.ctrl = false | |||
| 210 | + | case ev.Release: | |||
| 211 | + | case unicodeKey: | |||
| 212 | + | if _, digit := ev.Digit(); !digit { | |||
| 213 | + | n.ctrl = false | |||
| 214 | + | } | |||
| 215 | + | default: | |||
| 216 | + | n.ctrl = false | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // encodeOuter re-encodes one RequestFlags key event as the terminal would | |||
| 221 | + | // have sent it under OuterFlags, or nil when it would have sent nothing. | |||
| 222 | + | func encodeOuter(key fullKey) []byte { | |||
| 223 | + | ev := key.ev | |||
| 224 | + | if ev.Release || isModifierOrLock(ev.Code) { | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | switch { | |||
| 228 | + | case len(key.text) > 0 || ev.Code == 0: | |||
| 229 | + | // A terminal sends the text a key generates as is, whatever the | |||
| 230 | + | // modifiers that produced it. | |||
| 231 | + | return appendRunes(nil, key.text) | |||
| 232 | + | case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace): | |||
| 233 | + | return []byte{byte(ev.Code)} | |||
| 234 | + | case ev.Mods&^ModShift == 0 && isTextKey(ev.Code): | |||
| 235 | + | return legacyText(ev) | |||
| 236 | + | } | |||
| 237 | + | // A key sent as an escape code reports the lock bits too. | |||
| 238 | + | ev.Mods |= key.locks | |||
| 239 | + | return encodeCSIu(ev, OuterFlags) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func isModifierOrLock(code rune) bool { | |||
| 243 | + | return (code >= keyLeftShift && code <= keyISOLevel5) || (code >= keyCapsLock && code <= keyNumLock) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // isTextKey reports a key whose unmodified press produces its own text. | |||
| 247 | + | func isTextKey(code rune) bool { | |||
| 248 | + | return code >= 0x20 && code != KeyBackspace && (code < 0xe000 || code > 0xf8ff) && utf8.ValidRune(code) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func appendRunes(out []byte, runes []rune) []byte { | |||
| 252 | + | for _, r := range runes { | |||
| 253 | + | out = utf8.AppendRune(out, r) | |||
| 254 | + | } | |||
| 255 | + | return out | |||
| 256 | + | } | |||
A internal/usecase/keys/kittykey/normalize_test.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package kittykey | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | in string | |
| 14 | + | want string | |
| 15 | + | }{ | |
| 16 | + | {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"}, | |
| 17 | + | {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"}, | |
| 18 | + | {name: "text key", in: "\x1b[97;;97u", want: "a"}, | |
| 19 | + | {name: "text key without text field", in: "\x1b[97u", want: "a"}, | |
| 20 | + | {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"}, | |
| 21 | + | {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"}, | |
| 22 | + | {name: "composed text event", in: "\x1b[0;;229u", want: "å"}, | |
| 23 | + | {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"}, | |
| 24 | + | {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"}, | |
| 25 | + | {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"}, | |
| 26 | + | {name: "release dropped", in: "\x1b[97;1:3u", want: ""}, | |
| 27 | + | {name: "space", in: "\x1b[32;;32u", want: " "}, | |
| 28 | + | {name: "enter", in: "\x1b[13u", want: "\r"}, | |
| 29 | + | {name: "tab", in: "\x1b[9u", want: "\t"}, | |
| 30 | + | {name: "backspace", in: "\x1b[127u", want: "\x7f"}, | |
| 31 | + | {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"}, | |
| 32 | + | {name: "escape", in: "\x1b[27u", want: "\x1b[27u"}, | |
| 33 | + | {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"}, | |
| 34 | + | {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"}, | |
| 35 | + | {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"}, | |
| 36 | + | {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"}, | |
| 37 | + | {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)}, | |
| 38 | + | {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"}, | |
| 39 | + | {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"}, | |
| 40 | + | {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"}, | |
| 41 | + | {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"}, | |
| 42 | + | {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"}, | |
| 43 | + | {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"}, | |
| 44 | + | {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"}, | |
| 45 | + | {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""}, | |
| 46 | + | {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""}, | |
| 47 | + | {name: "shift key dropped", in: "\x1b[57441;2u", want: ""}, | |
| 48 | + | {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""}, | |
| 49 | + | {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"}, | |
| 50 | + | {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"}, | |
| 51 | + | {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""}, | |
| 52 | + | {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"}, | |
| 53 | + | {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"}, | |
| 54 | + | {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""}, | |
| 55 | + | {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"}, | |
| 56 | + | {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"}, | |
| 57 | + | {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"}, | |
| 58 | + | {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"}, | |
| 59 | + | {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"}, | |
| 60 | + | {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"}, | |
| 61 | + | {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"}, | |
| 62 | + | {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"}, | |
| 63 | + | {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"}, | |
| 64 | + | {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"}, | |
| 65 | + | } | |
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | var n Normalizer | |
| 69 | + | require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in)))) | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestNormalizerHoldsSplitSequences(t *testing.T) { | |
| 75 | + | tests := []struct { | |
| 76 | + | name string | |
| 77 | + | reads []string | |
| 78 | + | want []string | |
| 79 | + | }{ | |
| 80 | + | {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}}, | |
| 81 | + | {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}}, | |
| 82 | + | {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}}, | |
| 83 | + | {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}}, | |
| 84 | + | {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}}, | |
| 85 | + | {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}}, | |
| 86 | + | } | |
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | var n Normalizer | |
| 90 | + | for i, read := range tt.reads { | |
| 91 | + | require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i) | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte | |
| 98 | + | // and requires the same output and Ctrl state as one read. | |
| 99 | + | func TestNormalizerEverySplitMatchesOneRead(t *testing.T) { | |
| 100 | + | // Each input starts with a kitty key, as a terminal in RequestFlags mode | |
| 101 | + | // sends before any split can matter. | |
| 102 | + | inputs := []string{ | |
| 103 | + | "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u", | |
| 104 | + | "a\x1b[97;;97u\x1b[57442;1:3u", | |
| 105 | + | "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A", | |
| 106 | + | "\x1b[0;;19968:19968u\x1b[27u", | |
| 107 | + | } | |
| 108 | + | for _, in := range inputs { | |
| 109 | + | var whole Normalizer | |
| 110 | + | whole.Normalize([]byte("\x1b[27u")) | |
| 111 | + | want := string(whole.Normalize([]byte(in))) | |
| 112 | + | for cut := 1; cut < len(in); cut++ { | |
| 113 | + | var n Normalizer | |
| 114 | + | n.Normalize([]byte("\x1b[27u")) | |
| 115 | + | got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:]))) | |
| 116 | + | require.Equal(t, want, got, "input %q cut %d", in, cut) | |
| 117 | + | require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestNormalizerTracksCtrl(t *testing.T) { | |
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | reads []string | |
| 126 | + | held []bool | |
| 127 | + | }{ | |
| 128 | + | {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}}, | |
| 129 | + | {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}}, | |
| 130 | + | {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}}, | |
| 131 | + | {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}}, | |
| 132 | + | {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}}, | |
| 133 | + | {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}}, | |
| 134 | + | {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}}, | |
| 135 | + | {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}}, | |
| 136 | + | {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}}, | |
| 137 | + | {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}}, | |
| 138 | + | {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}}, | |
| 139 | + | {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}}, | |
| 140 | + | {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}}, | |
| 141 | + | {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}}, | |
| 142 | + | {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}}, | |
| 143 | + | } | |
| 144 | + | for _, tt := range tests { | |
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |
| 146 | + | var n Normalizer | |
| 147 | + | for i, read := range tt.reads { | |
| 148 | + | n.Normalize([]byte(read)) | |
| 149 | + | require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i) | |
| 150 | + | } | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | var n Normalizer | |
| 154 | + | n.Normalize([]byte("\x1b[57442;5u")) | |
| 155 | + | n.ReleaseCtrl() | |
| 156 | + | require.False(t, n.CtrlHeld()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func FuzzNormalize(f *testing.F) { | |
| 160 | + | for _, seed := range []string{"\x1b[97;;97u", "\x1b[57442;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5:3A", "a\x1b[", "\x1b[0;;229:7u"} { | |
| 161 | + | f.Add([]byte(seed)) | |
| 162 | + | } | |
| 163 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 164 | + | var n Normalizer | |
| 165 | + | out := n.Normalize(data) | |
| 166 | + | out = append(out, n.Normalize(nil)...) | |
| 167 | + | require.LessOrEqual(t, len(out), 4*len(data)+16) | |
| 168 | + | }) | |
| 169 | + | } | |
| @@ -0,0 +1,169 @@ | |||||
| 1 | + | package kittykey | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | in string | |||
| 14 | + | want string | |||
| 15 | + | }{ | |||
| 16 | + | {name: "legacy passthrough", in: "hello\x1b[A\x1bOP\r", want: "hello\x1b[A\x1bOP\r"}, | |||
| 17 | + | {name: "outer csi u passthrough", in: "\x1b[97;5u\x1b[38::49;5u", want: "\x1b[97;5u\x1b[38::49;5u"}, | |||
| 18 | + | {name: "text key", in: "\x1b[97;;97u", want: "a"}, | |||
| 19 | + | {name: "text key without text field", in: "\x1b[97u", want: "a"}, | |||
| 20 | + | {name: "shift text", in: "\x1b[97:65;2;65u", want: "A"}, | |||
| 21 | + | {name: "shift without text uses shifted", in: "\x1b[97:65;2u", want: "A"}, | |||
| 22 | + | {name: "composed text event", in: "\x1b[0;;229u", want: "å"}, | |||
| 23 | + | {name: "multi codepoint text", in: "\x1b[101;;101:769u", want: "e\u0301"}, | |||
| 24 | + | {name: "control codes dropped from text", in: "\x1b[0;;7:97u", want: "a"}, | |||
| 25 | + | {name: "repeat is press", in: "\x1b[97;1:2;97u", want: "a"}, | |||
| 26 | + | {name: "release dropped", in: "\x1b[97;1:3u", want: ""}, | |||
| 27 | + | {name: "space", in: "\x1b[32;;32u", want: " "}, | |||
| 28 | + | {name: "enter", in: "\x1b[13u", want: "\r"}, | |||
| 29 | + | {name: "tab", in: "\x1b[9u", want: "\t"}, | |||
| 30 | + | {name: "backspace", in: "\x1b[127u", want: "\x7f"}, | |||
| 31 | + | {name: "shift enter stays csi u", in: "\x1b[13;2u", want: "\x1b[13;2u"}, | |||
| 32 | + | {name: "escape", in: "\x1b[27u", want: "\x1b[27u"}, | |||
| 33 | + | {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"}, | |||
| 34 | + | {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"}, | |||
| 35 | + | {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"}, | |||
| 36 | + | {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"}, | |||
| 37 | + | {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)}, | |||
| 38 | + | {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"}, | |||
| 39 | + | {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"}, | |||
| 40 | + | {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"}, | |||
| 41 | + | {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"}, | |||
| 42 | + | {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"}, | |||
| 43 | + | {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"}, | |||
| 44 | + | {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"}, | |||
| 45 | + | {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""}, | |||
| 46 | + | {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""}, | |||
| 47 | + | {name: "shift key dropped", in: "\x1b[57441;2u", want: ""}, | |||
| 48 | + | {name: "caps lock dropped", in: "\x1b[57358;65u", want: ""}, | |||
| 49 | + | {name: "arrow unmodified passthrough", in: "\x1b[A", want: "\x1b[A"}, | |||
| 50 | + | {name: "arrow repeat", in: "\x1b[1;1:2A", want: "\x1b[A"}, | |||
| 51 | + | {name: "arrow release dropped", in: "\x1b[1;1:3A", want: ""}, | |||
| 52 | + | {name: "ctrl arrow", in: "\x1b[1;5C", want: "\x1b[1;5C"}, | |||
| 53 | + | {name: "ctrl arrow repeat", in: "\x1b[1;5:2C", want: "\x1b[1;5C"}, | |||
| 54 | + | {name: "delete release dropped", in: "\x1b[3;1:3~", want: ""}, | |||
| 55 | + | {name: "delete repeat", in: "\x1b[3;1:2~", want: "\x1b[3~"}, | |||
| 56 | + | {name: "ctrl delete", in: "\x1b[3;5~", want: "\x1b[3;5~"}, | |||
| 57 | + | {name: "mouse untouched", in: "\x1b[<0;1;1M", want: "\x1b[<0;1;1M"}, | |||
| 58 | + | {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"}, | |||
| 59 | + | {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"}, | |||
| 60 | + | {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"}, | |||
| 61 | + | {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"}, | |||
| 62 | + | {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"}, | |||
| 63 | + | {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"}, | |||
| 64 | + | {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"}, | |||
| 65 | + | } | |||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | var n Normalizer | |||
| 69 | + | require.Equal(t, tt.want, string(n.Normalize([]byte(tt.in)))) | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestNormalizerHoldsSplitSequences(t *testing.T) { | |||
| 75 | + | tests := []struct { | |||
| 76 | + | name string | |||
| 77 | + | reads []string | |||
| 78 | + | want []string | |||
| 79 | + | }{ | |||
| 80 | + | {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}}, | |||
| 81 | + | {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}}, | |||
| 82 | + | {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}}, | |||
| 83 | + | {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}}, | |||
| 84 | + | {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}}, | |||
| 85 | + | {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}}, | |||
| 86 | + | } | |||
| 87 | + | for _, tt := range tests { | |||
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 89 | + | var n Normalizer | |||
| 90 | + | for i, read := range tt.reads { | |||
| 91 | + | require.Equal(t, tt.want[i], string(n.Normalize([]byte(read))), "read %d", i) | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte | |||
| 98 | + | // and requires the same output and Ctrl state as one read. | |||
| 99 | + | func TestNormalizerEverySplitMatchesOneRead(t *testing.T) { | |||
| 100 | + | // Each input starts with a kitty key, as a terminal in RequestFlags mode | |||
| 101 | + | // sends before any split can matter. | |||
| 102 | + | inputs := []string{ | |||
| 103 | + | "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u", | |||
| 104 | + | "a\x1b[97;;97u\x1b[57442;1:3u", | |||
| 105 | + | "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A", | |||
| 106 | + | "\x1b[0;;19968:19968u\x1b[27u", | |||
| 107 | + | } | |||
| 108 | + | for _, in := range inputs { | |||
| 109 | + | var whole Normalizer | |||
| 110 | + | whole.Normalize([]byte("\x1b[27u")) | |||
| 111 | + | want := string(whole.Normalize([]byte(in))) | |||
| 112 | + | for cut := 1; cut < len(in); cut++ { | |||
| 113 | + | var n Normalizer | |||
| 114 | + | n.Normalize([]byte("\x1b[27u")) | |||
| 115 | + | got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:]))) | |||
| 116 | + | require.Equal(t, want, got, "input %q cut %d", in, cut) | |||
| 117 | + | require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestNormalizerTracksCtrl(t *testing.T) { | |||
| 123 | + | tests := []struct { | |||
| 124 | + | name string | |||
| 125 | + | reads []string | |||
| 126 | + | held []bool | |||
| 127 | + | }{ | |||
| 128 | + | {name: "left ctrl press and release", reads: []string{"\x1b[57442;5u", "\x1b[57442;1:3u"}, held: []bool{true, false}}, | |||
| 129 | + | {name: "right ctrl", reads: []string{"\x1b[57448;5u", "\x1b[57448;1:3u"}, held: []bool{true, false}}, | |||
| 130 | + | {name: "ctrl repeat keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57442;5:2u"}, held: []bool{true, true}}, | |||
| 131 | + | {name: "one of two ctrl released", reads: []string{"\x1b[57442;5u", "\x1b[57448;5u", "\x1b[57448;5:3u"}, held: []bool{true, true, true}}, | |||
| 132 | + | {name: "ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[49;5u", "\x1b[49;5:3u"}, held: []bool{true, true, true}}, | |||
| 133 | + | {name: "key without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[97;;97u"}, held: []bool{true, false}}, | |||
| 134 | + | {name: "arrow without ctrl clears", reads: []string{"\x1b[57442;5u", "\x1b[1;1:2A"}, held: []bool{true, false}}, | |||
| 135 | + | {name: "ctrl shortcut clears", reads: []string{"\x1b[57442;5u", "\x1b[114;5u", "\x1b[114;5:3u"}, held: []bool{true, false, false}}, | |||
| 136 | + | {name: "ctrl arrow clears", reads: []string{"\x1b[57442;5u", "\x1b[1;5C"}, held: []bool{true, false}}, | |||
| 137 | + | {name: "azerty ctrl digit keeps held", reads: []string{"\x1b[57442;5u", "\x1b[38::49;5u"}, held: []bool{true, true}}, | |||
| 138 | + | {name: "ctrl shift keeps held", reads: []string{"\x1b[57442;5u", "\x1b[57441;6u", "\x1b[57441;5:3u"}, held: []bool{true, true, true}}, | |||
| 139 | + | {name: "composed text keeps held", reads: []string{"\x1b[57442;5u", "\x1b[0;;229u"}, held: []bool{true, true}}, | |||
| 140 | + | {name: "legacy text never sets", reads: []string{"\x1b[97;5u"}, held: []bool{false}}, | |||
| 141 | + | {name: "shift does not set", reads: []string{"\x1b[57441;2u"}, held: []bool{false}}, | |||
| 142 | + | {name: "ctrl in paste ignored", reads: []string{"\x1b[200~\x1b[57442;5u\x1b[201~"}, held: []bool{false}}, | |||
| 143 | + | } | |||
| 144 | + | for _, tt := range tests { | |||
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 146 | + | var n Normalizer | |||
| 147 | + | for i, read := range tt.reads { | |||
| 148 | + | n.Normalize([]byte(read)) | |||
| 149 | + | require.Equal(t, tt.held[i], n.CtrlHeld(), "read %d", i) | |||
| 150 | + | } | |||
| 151 | + | }) | |||
| 152 | + | } | |||
| 153 | + | var n Normalizer | |||
| 154 | + | n.Normalize([]byte("\x1b[57442;5u")) | |||
| 155 | + | n.ReleaseCtrl() | |||
| 156 | + | require.False(t, n.CtrlHeld()) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func FuzzNormalize(f *testing.F) { | |||
| 160 | + | for _, seed := range []string{"\x1b[97;;97u", "\x1b[57442;5u", "\x1b[200~x\x1b[201~", "\x1b[1;5:3A", "a\x1b[", "\x1b[0;;229:7u"} { | |||
| 161 | + | f.Add([]byte(seed)) | |||
| 162 | + | } | |||
| 163 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 164 | + | var n Normalizer | |||
| 165 | + | out := n.Normalize(data) | |||
| 166 | + | out = append(out, n.Normalize(nil)...) | |||
| 167 | + | require.LessOrEqual(t, len(out), 4*len(data)+16) | |||
| 168 | + | }) | |||
| 169 | + | } | |||
M internal/usecase/theme/ramp.go +35 -4
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | - | import renderer "github.com/bnema/vev-vt" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | const ( | |
| 6 | 10 | normalTextContrast = 4.5 | |
| @@ -185,9 +189,36 @@ func mruWeight(index, count int) float64 { | |||
| 185 | 189 | return 0.5 * (1 - float64(index)/float64(count)) | |
| 186 | 190 | } | |
| 187 | 191 | ||
| 192 | + | // mruMaxTextDim is how far the oldest history entry's text fades toward its | |
| 193 | + | // background: it keeps 70% of its strength. | |
| 194 | + | const mruMaxTextDim = 0.3 | |
| 195 | + | ||
| 196 | + | // mruTextDim is the share of the way from the text color to the background for | |
| 197 | + | // a history entry: the newest keeps full text, the oldest fades by | |
| 198 | + | // mruMaxTextDim, linearly in between: 3 entries → 0, 0.15, 0.3. | |
| 199 | + | func mruTextDim(index, count int) float64 { | |
| 200 | + | if count <= 1 { | |
| 201 | + | return 0 | |
| 202 | + | } | |
| 203 | + | return mruMaxTextDim * float64(index) / float64(count-1) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // dimText fades text toward its surface by up to weight, never below | |
| 207 | + | // normal-text contrast unless the undimmed text was already below it. | |
| 208 | + | func dimText(text, surface renderer.RGB, weight float64) renderer.RGB { | |
| 209 | + | for step := int(math.Round(weight * 100)); step > 0; step-- { | |
| 210 | + | candidate := okLabLerp(text, surface, float64(step)/100) | |
| 211 | + | if ContrastRatio(candidate, surface) >= normalTextContrast { | |
| 212 | + | return candidate | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | return text | |
| 216 | + | } | |
| 217 | + | ||
| 188 | 218 | // MRUStyle places history entries by mruWeight between the bar and the active | |
| 189 | - | // surface. The background is never moved for contrast; only the text picks the | |
| 190 | - | // better of the theme foreground and background. | |
| 219 | + | // surface. The background is never moved for contrast; the text picks the | |
| 220 | + | // better of the theme foreground and background, then fades by mruTextDim | |
| 221 | + | // within normal-text contrast. | |
| 191 | 222 | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |
| 192 | 223 | if !ramp.rgb || count <= 0 { | |
| 193 | 224 | return ramp.SurfaceRecent | |
| @@ -196,5 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |||
| 196 | 227 | weight := mruWeight(index, count) | |
| 197 | 228 | background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight) | |
| 198 | 229 | foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background) | |
| 199 | - | return rgbSurface(foreground, background) | |
| 230 | + | return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background) | |
| 200 | 231 | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | - | import renderer "github.com/bnema/vev-vt" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | const ( | 9 | const ( | ||
| 6 | normalTextContrast = 4.5 | 10 | normalTextContrast = 4.5 | ||
| @@ -185,9 +189,36 @@ func mruWeight(index, count int) float64 { | |||||
| 185 | return 0.5 * (1 - float64(index)/float64(count)) | 189 | return 0.5 * (1 - float64(index)/float64(count)) | ||
| 186 | } | 190 | } | ||
| 187 | 191 | ||||
| 192 | + | // mruMaxTextDim is how far the oldest history entry's text fades toward its | |||
| 193 | + | // background: it keeps 70% of its strength. | |||
| 194 | + | const mruMaxTextDim = 0.3 | |||
| 195 | + | ||||
| 196 | + | // mruTextDim is the share of the way from the text color to the background for | |||
| 197 | + | // a history entry: the newest keeps full text, the oldest fades by | |||
| 198 | + | // mruMaxTextDim, linearly in between: 3 entries → 0, 0.15, 0.3. | |||
| 199 | + | func mruTextDim(index, count int) float64 { | |||
| 200 | + | if count <= 1 { | |||
| 201 | + | return 0 | |||
| 202 | + | } | |||
| 203 | + | return mruMaxTextDim * float64(index) / float64(count-1) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // dimText fades text toward its surface by up to weight, never below | |||
| 207 | + | // normal-text contrast unless the undimmed text was already below it. | |||
| 208 | + | func dimText(text, surface renderer.RGB, weight float64) renderer.RGB { | |||
| 209 | + | for step := int(math.Round(weight * 100)); step > 0; step-- { | |||
| 210 | + | candidate := okLabLerp(text, surface, float64(step)/100) | |||
| 211 | + | if ContrastRatio(candidate, surface) >= normalTextContrast { | |||
| 212 | + | return candidate | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | return text | |||
| 216 | + | } | |||
| 217 | + | ||||
| 188 | // MRUStyle places history entries by mruWeight between the bar and the active | 218 | // MRUStyle places history entries by mruWeight between the bar and the active | ||
| 189 | - | // surface. The background is never moved for contrast; only the text picks the | 219 | + | // surface. The background is never moved for contrast; the text picks the |
| 190 | - | // better of the theme foreground and background. | 220 | + | // better of the theme foreground and background, then fades by mruTextDim |
| 221 | + | // within normal-text contrast. | |||
| 191 | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | 222 | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | ||
| 192 | if !ramp.rgb || count <= 0 { | 223 | if !ramp.rgb || count <= 0 { | ||
| 193 | return ramp.SurfaceRecent | 224 | return ramp.SurfaceRecent | ||
| @@ -196,5 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |||||
| 196 | weight := mruWeight(index, count) | 227 | weight := mruWeight(index, count) | ||
| 197 | background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight) | 228 | background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight) | ||
| 198 | foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background) | 229 | foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background) | ||
| 199 | - | return rgbSurface(foreground, background) | 230 | + | return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background) |
| 200 | } | 231 | } | ||
M internal/usecase/theme/ramp_test.go +46 -1
| @@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) { | |||
| 160 | 160 | style := MRUStyle(ramp, index, count) | |
| 161 | 161 | require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index) | |
| 162 | 162 | foreground, _ := primaryText(tt.theme, style.BackgroundRGB) | |
| 163 | - | require.Equal(t, foreground, style.ForegroundRGB) | |
| 163 | + | require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB) | |
| 164 | + | if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast { | |
| 165 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index) | |
| 166 | + | } | |
| 164 | 167 | } | |
| 165 | 168 | } | |
| 166 | 169 | require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway") | |
| @@ -168,6 +171,48 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) { | |||
| 168 | 171 | } | |
| 169 | 172 | } | |
| 170 | 173 | ||
| 174 | + | func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) { | |
| 175 | + | tests := []struct { | |
| 176 | + | count int | |
| 177 | + | want []float64 | |
| 178 | + | }{ | |
| 179 | + | {count: 1, want: []float64{0}}, | |
| 180 | + | {count: 2, want: []float64{0, 0.3}}, | |
| 181 | + | {count: 3, want: []float64{0, 0.15, 0.3}}, | |
| 182 | + | {count: 4, want: []float64{0, 0.1, 0.2, 0.3}}, | |
| 183 | + | } | |
| 184 | + | for _, tt := range tests { | |
| 185 | + | for index, want := range tt.want { | |
| 186 | + | require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestDimTextStopsAtNormalTextContrast(t *testing.T) { | |
| 192 | + | black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80} | |
| 193 | + | tests := []struct { | |
| 194 | + | name string | |
| 195 | + | text renderer.RGB | |
| 196 | + | surface renderer.RGB | |
| 197 | + | want renderer.RGB | |
| 198 | + | }{ | |
| 199 | + | {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)}, | |
| 200 | + | {name: "marginal contrast fades only to the floor", text: grey, surface: black}, | |
| 201 | + | {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey}, | |
| 202 | + | } | |
| 203 | + | for _, tt := range tests { | |
| 204 | + | t.Run(tt.name, func(t *testing.T) { | |
| 205 | + | got := dimText(tt.text, tt.surface, 0.3) | |
| 206 | + | if tt.want != (renderer.RGB{}) { | |
| 207 | + | require.Equal(t, tt.want, got) | |
| 208 | + | } | |
| 209 | + | if ContrastRatio(tt.text, tt.surface) >= normalTextContrast { | |
| 210 | + | require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast) | |
| 211 | + | } | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 171 | 216 | func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) { | |
| 172 | 217 | ramp := neutralRamp(rampTheme(false)) | |
| 173 | 218 | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | |
| @@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) { | |||||
| 160 | style := MRUStyle(ramp, index, count) | 160 | style := MRUStyle(ramp, index, count) | ||
| 161 | require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index) | 161 | require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index) | ||
| 162 | foreground, _ := primaryText(tt.theme, style.BackgroundRGB) | 162 | foreground, _ := primaryText(tt.theme, style.BackgroundRGB) | ||
| 163 | - | require.Equal(t, foreground, style.ForegroundRGB) | 163 | + | require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB) |
| 164 | + | if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast { | |||
| 165 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index) | |||
| 166 | + | } | |||
| 164 | } | 167 | } | ||
| 165 | } | 168 | } | ||
| 166 | require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway") | 169 | require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway") | ||
| @@ -168,6 +171,48 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) { | |||||
| 168 | } | 171 | } | ||
| 169 | } | 172 | } | ||
| 170 | 173 | ||||
| 174 | + | func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) { | |||
| 175 | + | tests := []struct { | |||
| 176 | + | count int | |||
| 177 | + | want []float64 | |||
| 178 | + | }{ | |||
| 179 | + | {count: 1, want: []float64{0}}, | |||
| 180 | + | {count: 2, want: []float64{0, 0.3}}, | |||
| 181 | + | {count: 3, want: []float64{0, 0.15, 0.3}}, | |||
| 182 | + | {count: 4, want: []float64{0, 0.1, 0.2, 0.3}}, | |||
| 183 | + | } | |||
| 184 | + | for _, tt := range tests { | |||
| 185 | + | for index, want := range tt.want { | |||
| 186 | + | require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestDimTextStopsAtNormalTextContrast(t *testing.T) { | |||
| 192 | + | black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80} | |||
| 193 | + | tests := []struct { | |||
| 194 | + | name string | |||
| 195 | + | text renderer.RGB | |||
| 196 | + | surface renderer.RGB | |||
| 197 | + | want renderer.RGB | |||
| 198 | + | }{ | |||
| 199 | + | {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)}, | |||
| 200 | + | {name: "marginal contrast fades only to the floor", text: grey, surface: black}, | |||
| 201 | + | {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey}, | |||
| 202 | + | } | |||
| 203 | + | for _, tt := range tests { | |||
| 204 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 205 | + | got := dimText(tt.text, tt.surface, 0.3) | |||
| 206 | + | if tt.want != (renderer.RGB{}) { | |||
| 207 | + | require.Equal(t, tt.want, got) | |||
| 208 | + | } | |||
| 209 | + | if ContrastRatio(tt.text, tt.surface) >= normalTextContrast { | |||
| 210 | + | require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast) | |||
| 211 | + | } | |||
| 212 | + | }) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 171 | func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) { | 216 | func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) { | ||
| 172 | ramp := neutralRamp(rampTheme(false)) | 217 | ramp := neutralRamp(rampTheme(false)) | ||
| 173 | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | 218 | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | ||