feat(focus): only a focused vev client acknowledges attention bells (#284)
* feat(focus): report terminal window focus and let only a seen client clear a bell * fix(focus): declare terminal focus in Hello and draw a found bell before acknowledging it
- commit
- 7b5f8cfe0e6f89f78f8ffcd2203d8b7333cd6ae9
- author
- bnema · · 2026-09-24 10:15 UTC
- committer
- brice · · 2026-09-26 08:37 UTC
- parent
- 41d5105
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
35 files · +961 -133
M docs/architecture.md +4 -0
| @@ -90,6 +90,10 @@ A terminal resize reaches the client as one coalesced invalidation owned by the | |||
| 90 | 90 | ||
| 91 | 91 | Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons. | |
| 92 | 92 | ||
| 93 | + | ### Terminal focus | |
| 94 | + | ||
| 95 | + | 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. | |
| 96 | + | ||
| 93 | 97 | ## Broker-owned hosts and daemon composition | |
| 94 | 98 | ||
| 95 | 99 | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | |
| @@ -90,6 +90,10 @@ A terminal resize reaches the client as one coalesced invalidation owned by the | |||||
| 90 | 90 | ||||
| 91 | Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons. | 91 | Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons. | ||
| 92 | 92 | ||||
| 93 | + | ### Terminal focus | |||
| 94 | + | ||||
| 95 | + | 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. | |||
| 96 | + | ||||
| 93 | ## Broker-owned hosts and daemon composition | 97 | ## Broker-owned hosts and daemon composition | ||
| 94 | 98 | ||||
| 95 | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | 99 | `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`, `RemotePreviewWatch`, | |
| 15 | + | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `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`, `RemotePreviewWatch`, | 15 | + | `OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`, |
| 16 | `RouteAttentionSubscription`, `SamePeerSwitchRequest`, | 16 | `RouteAttentionSubscription`, `SamePeerSwitchRequest`, | ||
| 17 | `RecentRouteSnapshot`, `RouteNavigationFailure`, | 17 | `RecentRouteSnapshot`, `RouteNavigationFailure`, | ||
| 18 | `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`, | 18 | `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`, | ||
M docs/session-navigation.md +2 -0
| @@ -29,6 +29,8 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||
| 29 | 29 | ||
| 30 | 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | |
| 31 | 31 | ||
| 32 | + | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. | |
| 33 | + | ||
| 32 | 34 | ## Move panes and tabs | |
| 33 | 35 | ||
| 34 | 36 | - `MFP` moves the focused pane to another tab. | |
| @@ -29,6 +29,8 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||||
| 29 | 29 | ||||
| 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | ||
| 31 | 31 | ||||
| 32 | + | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. | |||
| 33 | + | ||||
| 32 | ## Move panes and tabs | 34 | ## Move panes and tabs | ||
| 33 | 35 | ||||
| 34 | - `MFP` moves the focused pane to another tab. | 36 | - `MFP` moves the focused pane to another tab. | ||
M internal/adapters/sessionwire/proto_convert_misc.go +20 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package sessionwire | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/protocol" | |
| 6 | 8 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -332,6 +334,24 @@ func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) { | |||
| 332 | 334 | return &wire.SelectTab{TabId: string(message.TabID)}, nil | |
| 333 | 335 | } | |
| 334 | 336 | ||
| 337 | + | func terminalFocusToWire(message protocol.TerminalFocus) (*wire.TerminalFocus, error) { | |
| 338 | + | if err := message.Validate(); err != nil { | |
| 339 | + | return nil, err | |
| 340 | + | } | |
| 341 | + | return &wire.TerminalFocus{Focus: uint32(message.Focus)}, nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func terminalFocusFromWire(message *wire.TerminalFocus) (protocol.TerminalFocus, error) { | |
| 345 | + | if message == nil || message.GetFocus() > math.MaxUint8 { | |
| 346 | + | return protocol.TerminalFocus{}, errProtoConvertRange | |
| 347 | + | } | |
| 348 | + | focus := protocol.TerminalFocus{Focus: domain.TerminalFocus(message.GetFocus())} | |
| 349 | + | if err := focus.Validate(); err != nil { | |
| 350 | + | return protocol.TerminalFocus{}, err | |
| 351 | + | } | |
| 352 | + | return focus, nil | |
| 353 | + | } | |
| 354 | + | ||
| 335 | 355 | func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) { | |
| 336 | 356 | if message == nil { | |
| 337 | 357 | return protocol.SelectTab{}, errProtoConvertRange | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package sessionwire | 1 | package sessionwire | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/protocol" | 7 | "github.com/bnema/vev/internal/protocol" | ||
| 6 | "github.com/bnema/vev/internal/protocol/wire" | 8 | "github.com/bnema/vev/internal/protocol/wire" | ||
| @@ -332,6 +334,24 @@ func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) { | |||||
| 332 | return &wire.SelectTab{TabId: string(message.TabID)}, nil | 334 | return &wire.SelectTab{TabId: string(message.TabID)}, nil | ||
| 333 | } | 335 | } | ||
| 334 | 336 | ||||
| 337 | + | func terminalFocusToWire(message protocol.TerminalFocus) (*wire.TerminalFocus, error) { | |||
| 338 | + | if err := message.Validate(); err != nil { | |||
| 339 | + | return nil, err | |||
| 340 | + | } | |||
| 341 | + | return &wire.TerminalFocus{Focus: uint32(message.Focus)}, nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func terminalFocusFromWire(message *wire.TerminalFocus) (protocol.TerminalFocus, error) { | |||
| 345 | + | if message == nil || message.GetFocus() > math.MaxUint8 { | |||
| 346 | + | return protocol.TerminalFocus{}, errProtoConvertRange | |||
| 347 | + | } | |||
| 348 | + | focus := protocol.TerminalFocus{Focus: domain.TerminalFocus(message.GetFocus())} | |||
| 349 | + | if err := focus.Validate(); err != nil { | |||
| 350 | + | return protocol.TerminalFocus{}, err | |||
| 351 | + | } | |||
| 352 | + | return focus, nil | |||
| 353 | + | } | |||
| 354 | + | ||||
| 335 | func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) { | 355 | func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) { | ||
| 336 | if message == nil { | 356 | if message == nil { | ||
| 337 | return protocol.SelectTab{}, errProtoConvertRange | 357 | return protocol.SelectTab{}, errProtoConvertRange | ||
M internal/adapters/sessionwire/proto_convert_range_test.go +20 -0
| @@ -60,6 +60,26 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) { | |||
| 60 | 60 | }, | |
| 61 | 61 | wantErr: errProtoConvertRange, | |
| 62 | 62 | }, | |
| 63 | + | { | |
| 64 | + | name: "hello terminal focus range", | |
| 65 | + | run: func(t *testing.T) error { | |
| 66 | + | hello := testWireHello(t) | |
| 67 | + | hello.TerminalFocus = 256 | |
| 68 | + | _, err := helloFromWire(hello) | |
| 69 | + | return err | |
| 70 | + | }, | |
| 71 | + | wantErr: errProtoConvertRange, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "hello terminal focus taxonomy", | |
| 75 | + | run: func(t *testing.T) error { | |
| 76 | + | hello := testWireHello(t) | |
| 77 | + | hello.TerminalFocus = 3 | |
| 78 | + | _, err := helloFromWire(hello) | |
| 79 | + | return err | |
| 80 | + | }, | |
| 81 | + | wantErr: protocol.ErrInvalidHello, | |
| 82 | + | }, | |
| 63 | 83 | { | |
| 64 | 84 | name: "hello geometry", | |
| 65 | 85 | run: func(t *testing.T) error { | |
| @@ -60,6 +60,26 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) { | |||||
| 60 | }, | 60 | }, | ||
| 61 | wantErr: errProtoConvertRange, | 61 | wantErr: errProtoConvertRange, | ||
| 62 | }, | 62 | }, | ||
| 63 | + | { | |||
| 64 | + | name: "hello terminal focus range", | |||
| 65 | + | run: func(t *testing.T) error { | |||
| 66 | + | hello := testWireHello(t) | |||
| 67 | + | hello.TerminalFocus = 256 | |||
| 68 | + | _, err := helloFromWire(hello) | |||
| 69 | + | return err | |||
| 70 | + | }, | |||
| 71 | + | wantErr: errProtoConvertRange, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "hello terminal focus taxonomy", | |||
| 75 | + | run: func(t *testing.T) error { | |||
| 76 | + | hello := testWireHello(t) | |||
| 77 | + | hello.TerminalFocus = 3 | |||
| 78 | + | _, err := helloFromWire(hello) | |||
| 79 | + | return err | |||
| 80 | + | }, | |||
| 81 | + | wantErr: protocol.ErrInvalidHello, | |||
| 82 | + | }, | |||
| 63 | { | 83 | { | ||
| 64 | name: "hello geometry", | 84 | name: "hello geometry", | ||
| 65 | run: func(t *testing.T) error { | 85 | run: func(t *testing.T) error { | ||
M internal/adapters/sessionwire/proto_convert_session.go +5 -0
| @@ -52,6 +52,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 52 | 52 | Remote: message.Remote, | |
| 53 | 53 | KittyDirectGraphics: message.KittyDirectGraphics, | |
| 54 | 54 | KittyKeyboard: message.KittyKeyboard, | |
| 55 | + | TerminalFocus: uint32(message.TerminalFocus), | |
| 55 | 56 | }, nil | |
| 56 | 57 | } | |
| 57 | 58 | ||
| @@ -135,6 +136,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||
| 135 | 136 | hello.Remote = message.GetRemote() | |
| 136 | 137 | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | |
| 137 | 138 | hello.KittyKeyboard = message.GetKittyKeyboard() | |
| 139 | + | hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus()) | |
| 140 | + | if err != nil { | |
| 141 | + | return protocol.Hello{}, err | |
| 142 | + | } | |
| 138 | 143 | if err := protocol.ValidateHello(hello); err != nil { | |
| 139 | 144 | return protocol.Hello{}, err | |
| 140 | 145 | } | |
| @@ -52,6 +52,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||||
| 52 | Remote: message.Remote, | 52 | Remote: message.Remote, | ||
| 53 | KittyDirectGraphics: message.KittyDirectGraphics, | 53 | KittyDirectGraphics: message.KittyDirectGraphics, | ||
| 54 | KittyKeyboard: message.KittyKeyboard, | 54 | KittyKeyboard: message.KittyKeyboard, | ||
| 55 | + | TerminalFocus: uint32(message.TerminalFocus), | |||
| 55 | }, nil | 56 | }, nil | ||
| 56 | } | 57 | } | ||
| 57 | 58 | ||||
| @@ -135,6 +136,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||||
| 135 | hello.Remote = message.GetRemote() | 136 | hello.Remote = message.GetRemote() | ||
| 136 | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | 137 | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | ||
| 137 | hello.KittyKeyboard = message.GetKittyKeyboard() | 138 | hello.KittyKeyboard = message.GetKittyKeyboard() | ||
| 139 | + | hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus()) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return protocol.Hello{}, err | |||
| 142 | + | } | |||
| 138 | if err := protocol.ValidateHello(hello); err != nil { | 143 | if err := protocol.ValidateHello(hello); err != nil { | ||
| 139 | return protocol.Hello{}, err | 144 | return protocol.Hello{}, err | ||
| 140 | } | 145 | } | ||
M internal/adapters/sessionwire/proto_convert_test.go +50 -0
| @@ -292,6 +292,56 @@ func TestProtoSelectTabWire(t *testing.T) { | |||
| 292 | 292 | require.Error(t, err, "an empty tab identity is never decoded") | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | + | // TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and | |
| 296 | + | // refuses unknown focus, a truncated prefix, and trailing garbage. | |
| 297 | + | func TestProtoTerminalFocusWire(t *testing.T) { | |
| 298 | + | tests := []struct { | |
| 299 | + | name string | |
| 300 | + | focus domain.TerminalFocus | |
| 301 | + | want []byte | |
| 302 | + | }{ | |
| 303 | + | // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}. | |
| 304 | + | {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}}, | |
| 305 | + | {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}}, | |
| 306 | + | } | |
| 307 | + | for _, tt := range tests { | |
| 308 | + | t.Run(tt.name, func(t *testing.T) { | |
| 309 | + | message := protocol.TerminalFocus{Focus: tt.focus} | |
| 310 | + | envelope, err := encodeProtoClient(message) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | raw, err := proto.Marshal(envelope) | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | require.Equal(t, tt.want, raw) | |
| 315 | + | ||
| 316 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |
| 317 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |
| 318 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |
| 319 | + | decoded := &wire.ClientEnvelope{} | |
| 320 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |
| 321 | + | got, err := decodeProtoClient(decoded) | |
| 322 | + | require.NoError(t, err) | |
| 323 | + | require.Equal(t, message, got) | |
| 324 | + | }) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | invalid := []struct { | |
| 328 | + | name string | |
| 329 | + | focus uint32 | |
| 330 | + | }{ | |
| 331 | + | {name: "unknown is never reported", focus: 0}, | |
| 332 | + | {name: "out of taxonomy", focus: 3}, | |
| 333 | + | {name: "out of range", focus: 256}, | |
| 334 | + | } | |
| 335 | + | for _, tt := range invalid { | |
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |
| 337 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}}) | |
| 338 | + | require.Error(t, err) | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | _, err := encodeProtoClient(protocol.TerminalFocus{}) | |
| 342 | + | require.Error(t, err, "unknown focus is never encoded") | |
| 343 | + | } | |
| 344 | + | ||
| 295 | 345 | func protoMessageName(message any) string { | |
| 296 | 346 | return protoShortName(message) | |
| 297 | 347 | } | |
| @@ -292,6 +292,56 @@ func TestProtoSelectTabWire(t *testing.T) { | |||||
| 292 | require.Error(t, err, "an empty tab identity is never decoded") | 292 | require.Error(t, err, "an empty tab identity is never decoded") | ||
| 293 | } | 293 | } | ||
| 294 | 294 | ||||
| 295 | + | // TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and | |||
| 296 | + | // refuses unknown focus, a truncated prefix, and trailing garbage. | |||
| 297 | + | func TestProtoTerminalFocusWire(t *testing.T) { | |||
| 298 | + | tests := []struct { | |||
| 299 | + | name string | |||
| 300 | + | focus domain.TerminalFocus | |||
| 301 | + | want []byte | |||
| 302 | + | }{ | |||
| 303 | + | // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}. | |||
| 304 | + | {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}}, | |||
| 305 | + | {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}}, | |||
| 306 | + | } | |||
| 307 | + | for _, tt := range tests { | |||
| 308 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 309 | + | message := protocol.TerminalFocus{Focus: tt.focus} | |||
| 310 | + | envelope, err := encodeProtoClient(message) | |||
| 311 | + | require.NoError(t, err) | |||
| 312 | + | raw, err := proto.Marshal(envelope) | |||
| 313 | + | require.NoError(t, err) | |||
| 314 | + | require.Equal(t, tt.want, raw) | |||
| 315 | + | ||||
| 316 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix") | |||
| 317 | + | require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage") | |||
| 318 | + | require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw)) | |||
| 319 | + | decoded := &wire.ClientEnvelope{} | |||
| 320 | + | require.NoError(t, proto.Unmarshal(raw, decoded)) | |||
| 321 | + | got, err := decodeProtoClient(decoded) | |||
| 322 | + | require.NoError(t, err) | |||
| 323 | + | require.Equal(t, message, got) | |||
| 324 | + | }) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | invalid := []struct { | |||
| 328 | + | name string | |||
| 329 | + | focus uint32 | |||
| 330 | + | }{ | |||
| 331 | + | {name: "unknown is never reported", focus: 0}, | |||
| 332 | + | {name: "out of taxonomy", focus: 3}, | |||
| 333 | + | {name: "out of range", focus: 256}, | |||
| 334 | + | } | |||
| 335 | + | for _, tt := range invalid { | |||
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 337 | + | _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}}) | |||
| 338 | + | require.Error(t, err) | |||
| 339 | + | }) | |||
| 340 | + | } | |||
| 341 | + | _, err := encodeProtoClient(protocol.TerminalFocus{}) | |||
| 342 | + | require.Error(t, err, "unknown focus is never encoded") | |||
| 343 | + | } | |||
| 344 | + | ||||
| 295 | func protoMessageName(message any) string { | 345 | func protoMessageName(message any) string { | ||
| 296 | return protoShortName(message) | 346 | return protoShortName(message) | ||
| 297 | } | 347 | } | ||
M internal/adapters/sessionwire/proto_envelope.go +13 -0
| @@ -159,6 +159,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||
| 159 | 159 | return nil, ErrInvalidMessage | |
| 160 | 160 | } | |
| 161 | 161 | return encodeProtoClient(*m) | |
| 162 | + | case protocol.TerminalFocus: | |
| 163 | + | converted, err := terminalFocusToWire(m) | |
| 164 | + | if err != nil { | |
| 165 | + | return nil, err | |
| 166 | + | } | |
| 167 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: converted}}, nil | |
| 168 | + | case *protocol.TerminalFocus: | |
| 169 | + | if m == nil { | |
| 170 | + | return nil, ErrInvalidMessage | |
| 171 | + | } | |
| 172 | + | return encodeProtoClient(*m) | |
| 162 | 173 | case protocol.UIFence: | |
| 163 | 174 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | |
| 164 | 175 | case *protocol.UIFence: | |
| @@ -643,6 +654,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 643 | 654 | return uiFenceFromWire(payload.UiFence) | |
| 644 | 655 | case *wire.ClientEnvelope_SelectTab: | |
| 645 | 656 | return selectTabFromWire(payload.SelectTab) | |
| 657 | + | case *wire.ClientEnvelope_TerminalFocus: | |
| 658 | + | return terminalFocusFromWire(payload.TerminalFocus) | |
| 646 | 659 | case *wire.ClientEnvelope_RemotePreviewWatch: | |
| 647 | 660 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | |
| 648 | 661 | case *wire.ClientEnvelope_RouteAttentionSubscription: | |
| @@ -159,6 +159,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||||
| 159 | return nil, ErrInvalidMessage | 159 | return nil, ErrInvalidMessage | ||
| 160 | } | 160 | } | ||
| 161 | return encodeProtoClient(*m) | 161 | return encodeProtoClient(*m) | ||
| 162 | + | case protocol.TerminalFocus: | |||
| 163 | + | converted, err := terminalFocusToWire(m) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return nil, err | |||
| 166 | + | } | |||
| 167 | + | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: converted}}, nil | |||
| 168 | + | case *protocol.TerminalFocus: | |||
| 169 | + | if m == nil { | |||
| 170 | + | return nil, ErrInvalidMessage | |||
| 171 | + | } | |||
| 172 | + | return encodeProtoClient(*m) | |||
| 162 | case protocol.UIFence: | 173 | case protocol.UIFence: | ||
| 163 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | 174 | return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil | ||
| 164 | case *protocol.UIFence: | 175 | case *protocol.UIFence: | ||
| @@ -643,6 +654,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||||
| 643 | return uiFenceFromWire(payload.UiFence) | 654 | return uiFenceFromWire(payload.UiFence) | ||
| 644 | case *wire.ClientEnvelope_SelectTab: | 655 | case *wire.ClientEnvelope_SelectTab: | ||
| 645 | return selectTabFromWire(payload.SelectTab) | 656 | return selectTabFromWire(payload.SelectTab) | ||
| 657 | + | case *wire.ClientEnvelope_TerminalFocus: | |||
| 658 | + | return terminalFocusFromWire(payload.TerminalFocus) | |||
| 646 | case *wire.ClientEnvelope_RemotePreviewWatch: | 659 | case *wire.ClientEnvelope_RemotePreviewWatch: | ||
| 647 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | 660 | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | ||
| 648 | case *wire.ClientEnvelope_RouteAttentionSubscription: | 661 | case *wire.ClientEnvelope_RouteAttentionSubscription: | ||
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -92,7 +92,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 92 | 92 | ||
| 93 | 93 | select { | |
| 94 | 94 | case got := <-captured: | |
| 95 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 95 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit | |
| 96 | 96 | if string(got) != want { | |
| 97 | 97 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 98 | 98 | } | |
| @@ -92,7 +92,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 92 | 92 | ||||
| 93 | select { | 93 | select { | ||
| 94 | case got := <-captured: | 94 | case got := <-captured: | ||
| 95 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | 95 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit |
| 96 | if string(got) != want { | 96 | if string(got) != want { | ||
| 97 | t.Fatalf("captured escapes = %q, want %q", got, want) | 97 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 98 | } | 98 | } | ||
M internal/adapters/term/terminal_test.go +4 -4
| @@ -62,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 62 | 62 | _ = outW.Close() | |
| 63 | 63 | <-done | |
| 64 | 64 | ||
| 65 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 65 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit | |
| 66 | 66 | if got := captured.String(); got != want { | |
| 67 | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 68 | 68 | } | |
| @@ -179,9 +179,9 @@ func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) { | |||
| 179 | 179 | // alt screen: a partial emission that got as far as cursorHide/mouseEnable | |
| 180 | 180 | // would otherwise leave the cursor hidden and mouse reporting on. | |
| 181 | 181 | const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable + | |
| 182 | - | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 182 | + | bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit | |
| 183 | 183 | const enterSequence = altScreenEnter + autowrapDisable + cursorHide + | |
| 184 | - | mouseEnable + bracketedPasteEnable + colorSchemeEnable | |
| 184 | + | mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable | |
| 185 | 185 | ||
| 186 | 186 | for _, tc := range []struct { | |
| 187 | 187 | name string | |
| @@ -280,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 280 | 280 | ||
| 281 | 281 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | |
| 282 | 282 | // exactly once, regardless of how many times EnterRaw/restore were called. | |
| 283 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 283 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit | |
| 284 | 284 | if got := captured.String(); got != want { | |
| 285 | 285 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 286 | 286 | } | |
| @@ -62,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 62 | _ = outW.Close() | 62 | _ = outW.Close() | ||
| 63 | <-done | 63 | <-done | ||
| 64 | 64 | ||||
| 65 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | 65 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit |
| 66 | if got := captured.String(); got != want { | 66 | if got := captured.String(); got != want { | ||
| 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 68 | } | 68 | } | ||
| @@ -179,9 +179,9 @@ func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) { | |||||
| 179 | // alt screen: a partial emission that got as far as cursorHide/mouseEnable | 179 | // alt screen: a partial emission that got as far as cursorHide/mouseEnable | ||
| 180 | // would otherwise leave the cursor hidden and mouse reporting on. | 180 | // would otherwise leave the cursor hidden and mouse reporting on. | ||
| 181 | const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable + | 181 | const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable + | ||
| 182 | - | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | 182 | + | bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit |
| 183 | const enterSequence = altScreenEnter + autowrapDisable + cursorHide + | 183 | const enterSequence = altScreenEnter + autowrapDisable + cursorHide + | ||
| 184 | - | mouseEnable + bracketedPasteEnable + colorSchemeEnable | 184 | + | mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable |
| 185 | 185 | ||||
| 186 | for _, tc := range []struct { | 186 | for _, tc := range []struct { | ||
| 187 | name string | 187 | name string | ||
| @@ -280,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 280 | 280 | ||||
| 281 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | 281 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | ||
| 282 | // exactly once, regardless of how many times EnterRaw/restore were called. | 282 | // exactly once, regardless of how many times EnterRaw/restore were called. | ||
| 283 | - | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | 283 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit |
| 284 | if got := captured.String(); got != want { | 284 | if got := captured.String(); got != want { | ||
| 285 | t.Fatalf("captured escapes = %q, want %q", got, want) | 285 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 286 | } | 286 | } | ||
M internal/adapters/term/visual.go +4 -2
| @@ -14,15 +14,17 @@ const ( | |||
| 14 | 14 | bracketedPasteDisable = "\x1b[?2004l" | |
| 15 | 15 | colorSchemeEnable = "\x1b[?2031h" | |
| 16 | 16 | colorSchemeDisable = "\x1b[?2031l" | |
| 17 | + | focusReportEnable = "\x1b[?1004h" | |
| 18 | + | focusReportDisable = "\x1b[?1004l" | |
| 17 | 19 | autowrapDisable = "\x1b[?7l" | |
| 18 | 20 | autowrapEnable = "\x1b[?7h" | |
| 19 | 21 | kittyKeyboardPop = "\x1b[<u" | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 22 | - | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable | |
| 24 | + | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable | |
| 23 | 25 | ||
| 24 | 26 | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | |
| 25 | - | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 27 | + | bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit | |
| 26 | 28 | ||
| 27 | 29 | // VisualEnterSequence returns a fresh copy of the visual initialization used | |
| 28 | 30 | // after acquiring a terminal. It intentionally excludes OS raw-mode setup. | |
| @@ -14,15 +14,17 @@ const ( | |||||
| 14 | bracketedPasteDisable = "\x1b[?2004l" | 14 | bracketedPasteDisable = "\x1b[?2004l" | ||
| 15 | colorSchemeEnable = "\x1b[?2031h" | 15 | colorSchemeEnable = "\x1b[?2031h" | ||
| 16 | colorSchemeDisable = "\x1b[?2031l" | 16 | colorSchemeDisable = "\x1b[?2031l" | ||
| 17 | + | focusReportEnable = "\x1b[?1004h" | |||
| 18 | + | focusReportDisable = "\x1b[?1004l" | |||
| 17 | autowrapDisable = "\x1b[?7l" | 19 | autowrapDisable = "\x1b[?7l" | ||
| 18 | autowrapEnable = "\x1b[?7h" | 20 | autowrapEnable = "\x1b[?7h" | ||
| 19 | kittyKeyboardPop = "\x1b[<u" | 21 | kittyKeyboardPop = "\x1b[<u" | ||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 22 | - | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable | 24 | + | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable |
| 23 | 25 | ||||
| 24 | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | 26 | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | ||
| 25 | - | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | 27 | + | bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit |
| 26 | 28 | ||||
| 27 | // VisualEnterSequence returns a fresh copy of the visual initialization used | 29 | // VisualEnterSequence returns a fresh copy of the visual initialization used | ||
| 28 | // after acquiring a terminal. It intentionally excludes OS raw-mode setup. | 30 | // after acquiring a terminal. It intentionally excludes OS raw-mode setup. | ||
M internal/adapters/webterm/input.go +2 -1
| @@ -90,7 +90,8 @@ func (t *Terminal) Handle(ctx context.Context, event browser.Event) error { | |||
| 90 | 90 | } | |
| 91 | 91 | data = sb.String() | |
| 92 | 92 | case browser.EventFocus: | |
| 93 | - | // Focus reporting is not negotiated by the current client terminal contract. | |
| 93 | + | // The browser page stays out of focus reporting: web clients keep | |
| 94 | + | // unknown focus and count as possibly seen. | |
| 94 | 95 | return nil | |
| 95 | 96 | } | |
| 96 | 97 | return t.Send(ctx, []byte(data)) | |
| @@ -90,7 +90,8 @@ func (t *Terminal) Handle(ctx context.Context, event browser.Event) error { | |||||
| 90 | } | 90 | } | ||
| 91 | data = sb.String() | 91 | data = sb.String() | ||
| 92 | case browser.EventFocus: | 92 | case browser.EventFocus: | ||
| 93 | - | // Focus reporting is not negotiated by the current client terminal contract. | 93 | + | // The browser page stays out of focus reporting: web clients keep |
| 94 | + | // unknown focus and count as possibly seen. | |||
| 94 | return nil | 95 | return nil | ||
| 95 | } | 96 | } | ||
| 96 | return t.Send(ctx, []byte(data)) | 97 | return t.Send(ctx, []byte(data)) | ||
A internal/domain/terminal_focus.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // TerminalFocus is whether the user's terminal window shows this client. | |
| 4 | + | // | |
| 5 | + | // A client learns it from terminal focus reporting (DEC mode 1004). Until the | |
| 6 | + | // terminal reports, or when it never does, focus is unknown, and callers must | |
| 7 | + | // treat an unknown client as possibly seen, exactly as before focus existed. | |
| 8 | + | type TerminalFocus uint8 | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | TerminalFocusUnknown TerminalFocus = iota | |
| 12 | + | TerminalFocusFocused | |
| 13 | + | TerminalFocusUnfocused | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Valid reports whether f is one of the closed focus states. | |
| 17 | + | func (f TerminalFocus) Valid() bool { | |
| 18 | + | return f <= TerminalFocusUnfocused | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // MaySee reports whether a user may be looking at this client. Only a | |
| 22 | + | // reported loss of focus rules that out. | |
| 23 | + | func (f TerminalFocus) MaySee() bool { | |
| 24 | + | return f != TerminalFocusUnfocused | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (f TerminalFocus) String() string { | |
| 28 | + | switch f { | |
| 29 | + | case TerminalFocusFocused: | |
| 30 | + | return "focused" | |
| 31 | + | case TerminalFocusUnfocused: | |
| 32 | + | return "unfocused" | |
| 33 | + | default: | |
| 34 | + | return "unknown" | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | // TerminalFocus is whether the user's terminal window shows this client. | |||
| 4 | + | // | |||
| 5 | + | // A client learns it from terminal focus reporting (DEC mode 1004). Until the | |||
| 6 | + | // terminal reports, or when it never does, focus is unknown, and callers must | |||
| 7 | + | // treat an unknown client as possibly seen, exactly as before focus existed. | |||
| 8 | + | type TerminalFocus uint8 | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | TerminalFocusUnknown TerminalFocus = iota | |||
| 12 | + | TerminalFocusFocused | |||
| 13 | + | TerminalFocusUnfocused | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Valid reports whether f is one of the closed focus states. | |||
| 17 | + | func (f TerminalFocus) Valid() bool { | |||
| 18 | + | return f <= TerminalFocusUnfocused | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // MaySee reports whether a user may be looking at this client. Only a | |||
| 22 | + | // reported loss of focus rules that out. | |||
| 23 | + | func (f TerminalFocus) MaySee() bool { | |||
| 24 | + | return f != TerminalFocusUnfocused | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (f TerminalFocus) String() string { | |||
| 28 | + | switch f { | |||
| 29 | + | case TerminalFocusFocused: | |||
| 30 | + | return "focused" | |||
| 31 | + | case TerminalFocusUnfocused: | |||
| 32 | + | return "unfocused" | |||
| 33 | + | default: | |||
| 34 | + | return "unknown" | |||
| 35 | + | } | |||
| 36 | + | } | |||
M internal/protocol/messages.go +1 -0
| @@ -20,6 +20,7 @@ func (ClientNotice) clientMessage() {} | |||
| 20 | 20 | func (CommandRequest) clientMessage() {} | |
| 21 | 21 | func (OutputResetRequest) clientMessage() {} | |
| 22 | 22 | func (SelectTab) clientMessage() {} | |
| 23 | + | func (TerminalFocus) clientMessage() {} | |
| 23 | 24 | func (RemotePreviewWatch) clientMessage() {} | |
| 24 | 25 | func (RouteAttentionSubscription) clientMessage() {} | |
| 25 | 26 | func (SamePeerSwitchRequest) clientMessage() {} | |
| @@ -20,6 +20,7 @@ func (ClientNotice) clientMessage() {} | |||||
| 20 | func (CommandRequest) clientMessage() {} | 20 | 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 (RemotePreviewWatch) clientMessage() {} | 24 | func (RemotePreviewWatch) clientMessage() {} | ||
| 24 | func (RouteAttentionSubscription) clientMessage() {} | 25 | func (RouteAttentionSubscription) clientMessage() {} | ||
| 25 | func (SamePeerSwitchRequest) clientMessage() {} | 26 | func (SamePeerSwitchRequest) clientMessage() {} | ||
M internal/protocol/session.go +21 -2
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 62 | |
| 19 | + | const Version uint16 = 63 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -51,6 +51,7 @@ var ( | |||
| 51 | 51 | ErrInvalidNavigation = errors.New("ports: invalid navigation") | |
| 52 | 52 | ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy") | |
| 53 | 53 | ErrInvalidClientNotice = errors.New("ports: invalid client notice") | |
| 54 | + | ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus") | |
| 54 | 55 | ErrInvalidDetached = errors.New("ports: invalid detached reason") | |
| 55 | 56 | ) | |
| 56 | 57 | ||
| @@ -124,6 +125,9 @@ type Hello struct { | |||
| 124 | 125 | EnvironmentPolicy EnvironmentPolicy | |
| 125 | 126 | NavigationCapabilities NavigationCapabilities | |
| 126 | 127 | Remote bool | |
| 128 | + | // TerminalFocus is the client's terminal window focus when it attaches, | |
| 129 | + | // so the attachment's first paint already knows whether it may be seen. | |
| 130 | + | TerminalFocus domain.TerminalFocus | |
| 127 | 131 | } | |
| 128 | 132 | ||
| 129 | 133 | func (h Hello) Geometry() domain.Geometry { | |
| @@ -364,6 +368,21 @@ func (m SelectTab) Validate() error { | |||
| 364 | 368 | return domain.ValidateTabStableID(m.TabID) | |
| 365 | 369 | } | |
| 366 | 370 | ||
| 371 | + | // TerminalFocus reports that the client's terminal window gained or lost | |
| 372 | + | // focus. A client sends it only after its terminal reports focus, so an | |
| 373 | + | // attachment that never sends it keeps unknown focus. | |
| 374 | + | type TerminalFocus struct { | |
| 375 | + | Focus domain.TerminalFocus | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Validate accepts only a reported state: unknown is the absence of a report. | |
| 379 | + | func (m TerminalFocus) Validate() error { | |
| 380 | + | if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused { | |
| 381 | + | return ErrInvalidTerminalFocus | |
| 382 | + | } | |
| 383 | + | return nil | |
| 384 | + | } | |
| 385 | + | ||
| 367 | 386 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | |
| 368 | 387 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | |
| 369 | 388 | } | |
| @@ -414,7 +433,7 @@ func ValidateHello(h Hello) error { | |||
| 414 | 433 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | |
| 415 | 434 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | |
| 416 | 435 | } | |
| 417 | - | if !validEnvironmentPolicy(h.EnvironmentPolicy) { | |
| 436 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() { | |
| 418 | 437 | return ErrInvalidHello | |
| 419 | 438 | } | |
| 420 | 439 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 18 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 62 | 19 | + | const Version uint16 = 63 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // first committed publication. It excludes the preceding client-local | ||
| @@ -51,6 +51,7 @@ var ( | |||||
| 51 | ErrInvalidNavigation = errors.New("ports: invalid navigation") | 51 | ErrInvalidNavigation = errors.New("ports: invalid navigation") | ||
| 52 | ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy") | 52 | ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy") | ||
| 53 | ErrInvalidClientNotice = errors.New("ports: invalid client notice") | 53 | ErrInvalidClientNotice = errors.New("ports: invalid client notice") | ||
| 54 | + | ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus") | |||
| 54 | ErrInvalidDetached = errors.New("ports: invalid detached reason") | 55 | ErrInvalidDetached = errors.New("ports: invalid detached reason") | ||
| 55 | ) | 56 | ) | ||
| 56 | 57 | ||||
| @@ -124,6 +125,9 @@ type Hello struct { | |||||
| 124 | EnvironmentPolicy EnvironmentPolicy | 125 | EnvironmentPolicy EnvironmentPolicy | ||
| 125 | NavigationCapabilities NavigationCapabilities | 126 | NavigationCapabilities NavigationCapabilities | ||
| 126 | Remote bool | 127 | Remote bool | ||
| 128 | + | // TerminalFocus is the client's terminal window focus when it attaches, | |||
| 129 | + | // so the attachment's first paint already knows whether it may be seen. | |||
| 130 | + | TerminalFocus domain.TerminalFocus | |||
| 127 | } | 131 | } | ||
| 128 | 132 | ||||
| 129 | func (h Hello) Geometry() domain.Geometry { | 133 | func (h Hello) Geometry() domain.Geometry { | ||
| @@ -364,6 +368,21 @@ func (m SelectTab) Validate() error { | |||||
| 364 | return domain.ValidateTabStableID(m.TabID) | 368 | return domain.ValidateTabStableID(m.TabID) | ||
| 365 | } | 369 | } | ||
| 366 | 370 | ||||
| 371 | + | // TerminalFocus reports that the client's terminal window gained or lost | |||
| 372 | + | // focus. A client sends it only after its terminal reports focus, so an | |||
| 373 | + | // attachment that never sends it keeps unknown focus. | |||
| 374 | + | type TerminalFocus struct { | |||
| 375 | + | Focus domain.TerminalFocus | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // Validate accepts only a reported state: unknown is the absence of a report. | |||
| 379 | + | func (m TerminalFocus) Validate() error { | |||
| 380 | + | if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused { | |||
| 381 | + | return ErrInvalidTerminalFocus | |||
| 382 | + | } | |||
| 383 | + | return nil | |||
| 384 | + | } | |||
| 385 | + | ||||
| 367 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | 386 | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | ||
| 368 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | 387 | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | ||
| 369 | } | 388 | } | ||
| @@ -414,7 +433,7 @@ func ValidateHello(h Hello) error { | |||||
| 414 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | 433 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | ||
| 415 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | 434 | return fmt.Errorf("%w: geometry", ErrInvalidHello) | ||
| 416 | } | 435 | } | ||
| 417 | - | if !validEnvironmentPolicy(h.EnvironmentPolicy) { | 436 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() { |
| 418 | return ErrInvalidHello | 437 | return ErrInvalidHello | ||
| 419 | } | 438 | } | ||
| 420 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | 439 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(62), Version) | |
| 13 | + | require.Equal(t, uint16(63), 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(62), Version) | 13 | + | require.Equal(t, uint16(63), 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 generated file: collapsed
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 != 62 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 62", protocol.Version) | |
| 18 | + | if protocol.Version != 63 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 63", 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, | |
| 43 | + | "select_tab": true, "terminal_focus": 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 != 62 { | 18 | + | if protocol.Version != 63 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 62", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 63", 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, | 43 | + | "select_tab": true, "terminal_focus": 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
| @@ -99,6 +99,7 @@ message ClientEnvelope { | |||
| 99 | 99 | ActivateAttachment activate_attachment = 30; | |
| 100 | 100 | SelectTab select_tab = 31; | |
| 101 | 101 | RemotePreviewWatch remote_preview_watch = 32; | |
| 102 | + | TerminalFocus terminal_focus = 33; | |
| 102 | 103 | } | |
| 103 | 104 | } | |
| 104 | 105 | ||
| @@ -99,6 +99,7 @@ message ClientEnvelope { | |||||
| 99 | ActivateAttachment activate_attachment = 30; | 99 | ActivateAttachment activate_attachment = 30; | ||
| 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 | } | 103 | } | ||
| 103 | } | 104 | } | ||
| 104 | 105 | ||||
M internal/protocol/wire/schema/session.proto +3 -0
| @@ -34,6 +34,9 @@ message Hello { | |||
| 34 | 34 | bool kitty_direct_graphics = 21; | |
| 35 | 35 | SessionAttachTarget session_target = 22; // optional pointer | |
| 36 | 36 | bool kitty_keyboard = 23; | |
| 37 | + | // Terminal window focus when the attachment opens: 0 = unknown, | |
| 38 | + | // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus. | |
| 39 | + | uint32 terminal_focus = 24; | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | // Welcome answers an accepted attach. | |
| @@ -34,6 +34,9 @@ message Hello { | |||||
| 34 | bool kitty_direct_graphics = 21; | 34 | bool kitty_direct_graphics = 21; | ||
| 35 | SessionAttachTarget session_target = 22; // optional pointer | 35 | SessionAttachTarget session_target = 22; // optional pointer | ||
| 36 | bool kitty_keyboard = 23; | 36 | bool kitty_keyboard = 23; | ||
| 37 | + | // Terminal window focus when the attachment opens: 0 = unknown, | |||
| 38 | + | // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus. | |||
| 39 | + | uint32 terminal_focus = 24; | |||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 39 | // Welcome answers an accepted attach. | 42 | // Welcome answers an accepted attach. | ||
M internal/protocol/wire/schema/terminal.proto +6 -0
| @@ -86,6 +86,12 @@ message ClientNotice { | |||
| 86 | 86 | // OutputResetRequest restarts the output chain. Empty marker. | |
| 87 | 87 | message OutputResetRequest {} | |
| 88 | 88 | ||
| 89 | + | // TerminalFocus reports a focus change of the client's terminal window. | |
| 90 | + | message TerminalFocus { | |
| 91 | + | // Closed focus taxonomy: 1 = focused, 2 = unfocused. | |
| 92 | + | uint32 focus = 1; | |
| 93 | + | } | |
| 94 | + | ||
| 89 | 95 | // SelectTab shows one exact tab of the attached session on this attachment. | |
| 90 | 96 | message SelectTab { | |
| 91 | 97 | string tab_id = 1; | |
| @@ -86,6 +86,12 @@ message ClientNotice { | |||||
| 86 | // OutputResetRequest restarts the output chain. Empty marker. | 86 | // OutputResetRequest restarts the output chain. Empty marker. | ||
| 87 | message OutputResetRequest {} | 87 | message OutputResetRequest {} | ||
| 88 | 88 | ||||
| 89 | + | // TerminalFocus reports a focus change of the client's terminal window. | |||
| 90 | + | message TerminalFocus { | |||
| 91 | + | // Closed focus taxonomy: 1 = focused, 2 = unfocused. | |||
| 92 | + | uint32 focus = 1; | |||
| 93 | + | } | |||
| 94 | + | ||||
| 89 | // SelectTab shows one exact tab of the attached session on this attachment. | 95 | // SelectTab shows one exact tab of the attached session on this attachment. | ||
| 90 | message SelectTab { | 96 | message SelectTab { | ||
| 91 | string tab_id = 1; | 97 | string tab_id = 1; | ||
M internal/protocol/wire/session.pb.go +15 -4 generated file: collapsed
M internal/protocol/wire/terminal.pb.go +93 -44 generated file: collapsed
M internal/usecase/client/attachment_session.go +28 -1
| @@ -324,8 +324,27 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 324 | 324 | if err := input.start(ctx); err != nil { | |
| 325 | 325 | return w.settle(ctx, fg, stream, token, err) | |
| 326 | 326 | } | |
| 327 | + | // Hello declared the focus known at attach, for the first paint. The | |
| 328 | + | // reporter re-sends the current focus, which the daemon ignores when | |
| 329 | + | // unchanged, then every change. The pump records a focus report before it | |
| 330 | + | // delivers the input read with it, so flushing focus first keeps the | |
| 331 | + | // daemon's view in input order. | |
| 332 | + | focus := &focusReporter{state: attachmentTerminalFocus(fg)} | |
| 333 | + | sendFocus := func() error { | |
| 334 | + | if message, ok := focus.next(); ok { | |
| 335 | + | return w.send(ctx, fg, stream, message) | |
| 336 | + | } | |
| 337 | + | return nil | |
| 338 | + | } | |
| 339 | + | if err := sendFocus(); err != nil { | |
| 340 | + | return w.settle(ctx, fg, stream, token, err) | |
| 341 | + | } | |
| 327 | 342 | for { | |
| 328 | 343 | select { | |
| 344 | + | case <-focus.changed: | |
| 345 | + | if err := sendFocus(); err != nil { | |
| 346 | + | return w.settle(ctx, fg, stream, token, err) | |
| 347 | + | } | |
| 329 | 348 | case <-input.wake: | |
| 330 | 349 | if err := input.flush(ctx); err != nil { | |
| 331 | 350 | return w.settle(ctx, fg, stream, token, err) | |
| @@ -480,6 +499,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 480 | 499 | fg.PreserveInput(event.input.Data) | |
| 481 | 500 | return w.settle(ctx, fg, stream, token, event.input.Err) | |
| 482 | 501 | } | |
| 502 | + | if event.input != nil { | |
| 503 | + | if err := sendFocus(); err != nil { | |
| 504 | + | fg.PreserveInput(event.input.Data) | |
| 505 | + | return w.settle(ctx, fg, stream, token, err) | |
| 506 | + | } | |
| 507 | + | } | |
| 483 | 508 | if event.input != nil && event.input.actionID == 0 { | |
| 484 | 509 | // A physical read: replies are stripped and ordinary bytes | |
| 485 | 510 | // reach the overlay or the session inside read. | |
| @@ -685,7 +710,9 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex | |||
| 685 | 710 | ||
| 686 | 711 | // sendHello sends the typed Hello derived from the exact opened request. | |
| 687 | 712 | func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error { | |
| 688 | - | return w.send(ctx, fg, stream, w.hello(stream)) | |
| 713 | + | hello := w.hello(stream) | |
| 714 | + | hello.TerminalFocus = attachmentTerminalFocus(fg).Focus() | |
| 715 | + | return w.send(ctx, fg, stream, hello) | |
| 689 | 716 | } | |
| 690 | 717 | ||
| 691 | 718 | // hello builds the Hello the daemon expects for exactly one opened request. No | |
| @@ -324,8 +324,27 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 324 | if err := input.start(ctx); err != nil { | 324 | if err := input.start(ctx); err != nil { | ||
| 325 | return w.settle(ctx, fg, stream, token, err) | 325 | return w.settle(ctx, fg, stream, token, err) | ||
| 326 | } | 326 | } | ||
| 327 | + | // Hello declared the focus known at attach, for the first paint. The | |||
| 328 | + | // reporter re-sends the current focus, which the daemon ignores when | |||
| 329 | + | // unchanged, then every change. The pump records a focus report before it | |||
| 330 | + | // delivers the input read with it, so flushing focus first keeps the | |||
| 331 | + | // daemon's view in input order. | |||
| 332 | + | focus := &focusReporter{state: attachmentTerminalFocus(fg)} | |||
| 333 | + | sendFocus := func() error { | |||
| 334 | + | if message, ok := focus.next(); ok { | |||
| 335 | + | return w.send(ctx, fg, stream, message) | |||
| 336 | + | } | |||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 339 | + | if err := sendFocus(); err != nil { | |||
| 340 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 341 | + | } | |||
| 327 | for { | 342 | for { | ||
| 328 | select { | 343 | select { | ||
| 344 | + | case <-focus.changed: | |||
| 345 | + | if err := sendFocus(); err != nil { | |||
| 346 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 347 | + | } | |||
| 329 | case <-input.wake: | 348 | case <-input.wake: | ||
| 330 | if err := input.flush(ctx); err != nil { | 349 | if err := input.flush(ctx); err != nil { | ||
| 331 | return w.settle(ctx, fg, stream, token, err) | 350 | return w.settle(ctx, fg, stream, token, err) | ||
| @@ -480,6 +499,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 480 | fg.PreserveInput(event.input.Data) | 499 | fg.PreserveInput(event.input.Data) | ||
| 481 | return w.settle(ctx, fg, stream, token, event.input.Err) | 500 | return w.settle(ctx, fg, stream, token, event.input.Err) | ||
| 482 | } | 501 | } | ||
| 502 | + | if event.input != nil { | |||
| 503 | + | if err := sendFocus(); err != nil { | |||
| 504 | + | fg.PreserveInput(event.input.Data) | |||
| 505 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 483 | if event.input != nil && event.input.actionID == 0 { | 508 | if event.input != nil && event.input.actionID == 0 { | ||
| 484 | // A physical read: replies are stripped and ordinary bytes | 509 | // A physical read: replies are stripped and ordinary bytes | ||
| 485 | // reach the overlay or the session inside read. | 510 | // reach the overlay or the session inside read. | ||
| @@ -685,7 +710,9 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex | |||||
| 685 | 710 | ||||
| 686 | // sendHello sends the typed Hello derived from the exact opened request. | 711 | // sendHello sends the typed Hello derived from the exact opened request. | ||
| 687 | func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error { | 712 | func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error { | ||
| 688 | - | return w.send(ctx, fg, stream, w.hello(stream)) | 713 | + | hello := w.hello(stream) |
| 714 | + | hello.TerminalFocus = attachmentTerminalFocus(fg).Focus() | |||
| 715 | + | return w.send(ctx, fg, stream, hello) | |||
| 689 | } | 716 | } | ||
| 690 | 717 | ||||
| 691 | // hello builds the Hello the daemon expects for exactly one opened request. No | 718 | // hello builds the Hello the daemon expects for exactly one opened request. No | ||
A internal/usecase/client/terminal_focus.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Terminal focus. | |
| 12 | + | // | |
| 13 | + | // The client turns on focus reporting (DEC mode 1004) with its other terminal | |
| 14 | + | // modes. The terminal then sends ESC [ I when its window gains focus and | |
| 15 | + | // ESC [ O when it loses it. terminalFocusScanner strips both reports from the | |
| 16 | + | // input stream and keeps the latest state, so any client feature can read | |
| 17 | + | // Focus() and the attached loop tells the daemon about each change. | |
| 18 | + | // | |
| 19 | + | // A terminal without focus reporting sends nothing: focus stays unknown and | |
| 20 | + | // every feature keeps its pre-focus behavior. | |
| 21 | + | ||
| 22 | + | var ( | |
| 23 | + | terminalFocusInReport = []byte("\x1b[I") | |
| 24 | + | terminalFocusOutReport = []byte("\x1b[O") | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // TerminalFocusState is the client's one terminal focus. The terminal input | |
| 28 | + | // pump is its only writer; any client feature may read it or wait for a | |
| 29 | + | // change. It outlives attachments, so a new attachment starts from the focus | |
| 30 | + | // the terminal last reported. | |
| 31 | + | type TerminalFocusState struct { | |
| 32 | + | mu sync.Mutex | |
| 33 | + | focus domain.TerminalFocus | |
| 34 | + | changed chan struct{} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newTerminalFocusState() *TerminalFocusState { | |
| 38 | + | return &TerminalFocusState{changed: make(chan struct{})} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Focus returns the latest reported focus. A nil state is unknown. | |
| 42 | + | func (s *TerminalFocusState) Focus() domain.TerminalFocus { | |
| 43 | + | if s == nil { | |
| 44 | + | return domain.TerminalFocusUnknown | |
| 45 | + | } | |
| 46 | + | s.mu.Lock() | |
| 47 | + | defer s.mu.Unlock() | |
| 48 | + | return s.focus | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Watch returns the current focus and a channel closed on the next change. | |
| 52 | + | func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) { | |
| 53 | + | if s == nil { | |
| 54 | + | return domain.TerminalFocusUnknown, nil | |
| 55 | + | } | |
| 56 | + | s.mu.Lock() | |
| 57 | + | defer s.mu.Unlock() | |
| 58 | + | return s.focus, s.changed | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *TerminalFocusState) set(focus domain.TerminalFocus) { | |
| 62 | + | s.mu.Lock() | |
| 63 | + | defer s.mu.Unlock() | |
| 64 | + | if focus == s.focus { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | s.focus = focus | |
| 68 | + | close(s.changed) | |
| 69 | + | s.changed = make(chan struct{}) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // attachmentFocusForeground is the optional seam through which an attached | |
| 73 | + | // loop reads the terminal focus. | |
| 74 | + | type attachmentFocusForeground interface { | |
| 75 | + | terminalFocus() *TerminalFocusState | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (f *attachmentForeground) terminalFocus() *TerminalFocusState { | |
| 79 | + | if f == nil || f.input == nil { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | return f.input.focus | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // attachmentTerminalFocus returns the focus state of fg, or nil. | |
| 86 | + | func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState { | |
| 87 | + | if focus, ok := fg.(attachmentFocusForeground); ok { | |
| 88 | + | return focus.terminalFocus() | |
| 89 | + | } | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // focusReporter sends the terminal focus to the daemon: the current state once | |
| 94 | + | // the attachment starts, then every change. Unknown focus is never sent. | |
| 95 | + | type focusReporter struct { | |
| 96 | + | state *TerminalFocusState | |
| 97 | + | sent domain.TerminalFocus | |
| 98 | + | changed <-chan struct{} | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // next returns the message to send now, if any, and re-arms the change wake. | |
| 102 | + | func (r *focusReporter) next() (protocol.TerminalFocus, bool) { | |
| 103 | + | focus, changed := r.state.Watch() | |
| 104 | + | r.changed = changed | |
| 105 | + | if focus == domain.TerminalFocusUnknown || focus == r.sent { | |
| 106 | + | return protocol.TerminalFocus{}, false | |
| 107 | + | } | |
| 108 | + | r.sent = focus | |
| 109 | + | return protocol.TerminalFocus{Focus: focus}, true | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // stripTerminalFocusReports removes complete focus reports from data and | |
| 113 | + | // records the last one in state. It never withholds a prefix: a terminal | |
| 114 | + | // writes each report whole, while a lone Escape or Alt+[ typed by the user | |
| 115 | + | // must reach the session without delay. Other bytes keep their order. A | |
| 116 | + | // pasted literal report, or one split across reads, is accepted as rare. | |
| 117 | + | func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte { | |
| 118 | + | at, focus := nextTerminalFocusReport(data) | |
| 119 | + | if at < 0 { | |
| 120 | + | return data | |
| 121 | + | } | |
| 122 | + | out := make([]byte, 0, len(data)) | |
| 123 | + | for at >= 0 { | |
| 124 | + | out = append(out, data[:at]...) | |
| 125 | + | state.set(focus) | |
| 126 | + | data = data[at+len(terminalFocusInReport):] | |
| 127 | + | at, focus = nextTerminalFocusReport(data) | |
| 128 | + | } | |
| 129 | + | return append(out, data...) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // nextTerminalFocusReport returns the offset of the first complete report in | |
| 133 | + | // data, or -1. | |
| 134 | + | func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) { | |
| 135 | + | in := bytes.Index(data, terminalFocusInReport) | |
| 136 | + | out := bytes.Index(data, terminalFocusOutReport) | |
| 137 | + | switch { | |
| 138 | + | case in < 0 && out < 0: | |
| 139 | + | return -1, domain.TerminalFocusUnknown | |
| 140 | + | case out < 0 || (in >= 0 && in < out): | |
| 141 | + | return in, domain.TerminalFocusFocused | |
| 142 | + | default: | |
| 143 | + | return out, domain.TerminalFocusUnfocused | |
| 144 | + | } | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/protocol" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Terminal focus. | |||
| 12 | + | // | |||
| 13 | + | // The client turns on focus reporting (DEC mode 1004) with its other terminal | |||
| 14 | + | // modes. The terminal then sends ESC [ I when its window gains focus and | |||
| 15 | + | // ESC [ O when it loses it. terminalFocusScanner strips both reports from the | |||
| 16 | + | // input stream and keeps the latest state, so any client feature can read | |||
| 17 | + | // Focus() and the attached loop tells the daemon about each change. | |||
| 18 | + | // | |||
| 19 | + | // A terminal without focus reporting sends nothing: focus stays unknown and | |||
| 20 | + | // every feature keeps its pre-focus behavior. | |||
| 21 | + | ||||
| 22 | + | var ( | |||
| 23 | + | terminalFocusInReport = []byte("\x1b[I") | |||
| 24 | + | terminalFocusOutReport = []byte("\x1b[O") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // TerminalFocusState is the client's one terminal focus. The terminal input | |||
| 28 | + | // pump is its only writer; any client feature may read it or wait for a | |||
| 29 | + | // change. It outlives attachments, so a new attachment starts from the focus | |||
| 30 | + | // the terminal last reported. | |||
| 31 | + | type TerminalFocusState struct { | |||
| 32 | + | mu sync.Mutex | |||
| 33 | + | focus domain.TerminalFocus | |||
| 34 | + | changed chan struct{} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func newTerminalFocusState() *TerminalFocusState { | |||
| 38 | + | return &TerminalFocusState{changed: make(chan struct{})} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Focus returns the latest reported focus. A nil state is unknown. | |||
| 42 | + | func (s *TerminalFocusState) Focus() domain.TerminalFocus { | |||
| 43 | + | if s == nil { | |||
| 44 | + | return domain.TerminalFocusUnknown | |||
| 45 | + | } | |||
| 46 | + | s.mu.Lock() | |||
| 47 | + | defer s.mu.Unlock() | |||
| 48 | + | return s.focus | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Watch returns the current focus and a channel closed on the next change. | |||
| 52 | + | func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) { | |||
| 53 | + | if s == nil { | |||
| 54 | + | return domain.TerminalFocusUnknown, nil | |||
| 55 | + | } | |||
| 56 | + | s.mu.Lock() | |||
| 57 | + | defer s.mu.Unlock() | |||
| 58 | + | return s.focus, s.changed | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *TerminalFocusState) set(focus domain.TerminalFocus) { | |||
| 62 | + | s.mu.Lock() | |||
| 63 | + | defer s.mu.Unlock() | |||
| 64 | + | if focus == s.focus { | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | s.focus = focus | |||
| 68 | + | close(s.changed) | |||
| 69 | + | s.changed = make(chan struct{}) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // attachmentFocusForeground is the optional seam through which an attached | |||
| 73 | + | // loop reads the terminal focus. | |||
| 74 | + | type attachmentFocusForeground interface { | |||
| 75 | + | terminalFocus() *TerminalFocusState | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (f *attachmentForeground) terminalFocus() *TerminalFocusState { | |||
| 79 | + | if f == nil || f.input == nil { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | return f.input.focus | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // attachmentTerminalFocus returns the focus state of fg, or nil. | |||
| 86 | + | func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState { | |||
| 87 | + | if focus, ok := fg.(attachmentFocusForeground); ok { | |||
| 88 | + | return focus.terminalFocus() | |||
| 89 | + | } | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // focusReporter sends the terminal focus to the daemon: the current state once | |||
| 94 | + | // the attachment starts, then every change. Unknown focus is never sent. | |||
| 95 | + | type focusReporter struct { | |||
| 96 | + | state *TerminalFocusState | |||
| 97 | + | sent domain.TerminalFocus | |||
| 98 | + | changed <-chan struct{} | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // next returns the message to send now, if any, and re-arms the change wake. | |||
| 102 | + | func (r *focusReporter) next() (protocol.TerminalFocus, bool) { | |||
| 103 | + | focus, changed := r.state.Watch() | |||
| 104 | + | r.changed = changed | |||
| 105 | + | if focus == domain.TerminalFocusUnknown || focus == r.sent { | |||
| 106 | + | return protocol.TerminalFocus{}, false | |||
| 107 | + | } | |||
| 108 | + | r.sent = focus | |||
| 109 | + | return protocol.TerminalFocus{Focus: focus}, true | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // stripTerminalFocusReports removes complete focus reports from data and | |||
| 113 | + | // records the last one in state. It never withholds a prefix: a terminal | |||
| 114 | + | // writes each report whole, while a lone Escape or Alt+[ typed by the user | |||
| 115 | + | // must reach the session without delay. Other bytes keep their order. A | |||
| 116 | + | // pasted literal report, or one split across reads, is accepted as rare. | |||
| 117 | + | func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte { | |||
| 118 | + | at, focus := nextTerminalFocusReport(data) | |||
| 119 | + | if at < 0 { | |||
| 120 | + | return data | |||
| 121 | + | } | |||
| 122 | + | out := make([]byte, 0, len(data)) | |||
| 123 | + | for at >= 0 { | |||
| 124 | + | out = append(out, data[:at]...) | |||
| 125 | + | state.set(focus) | |||
| 126 | + | data = data[at+len(terminalFocusInReport):] | |||
| 127 | + | at, focus = nextTerminalFocusReport(data) | |||
| 128 | + | } | |||
| 129 | + | return append(out, data...) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // nextTerminalFocusReport returns the offset of the first complete report in | |||
| 133 | + | // data, or -1. | |||
| 134 | + | func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) { | |||
| 135 | + | in := bytes.Index(data, terminalFocusInReport) | |||
| 136 | + | out := bytes.Index(data, terminalFocusOutReport) | |||
| 137 | + | switch { | |||
| 138 | + | case in < 0 && out < 0: | |||
| 139 | + | return -1, domain.TerminalFocusUnknown | |||
| 140 | + | case out < 0 || (in >= 0 && in < out): | |||
| 141 | + | return in, domain.TerminalFocusFocused | |||
| 142 | + | default: | |||
| 143 | + | return out, domain.TerminalFocusUnfocused | |||
| 144 | + | } | |||
| 145 | + | } | |||
A internal/usecase/client/terminal_focus_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/protocol" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestStripTerminalFocusReports(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | start domain.TerminalFocus | |
| 20 | + | input string | |
| 21 | + | want string | |
| 22 | + | wantFocus domain.TerminalFocus | |
| 23 | + | changed bool | |
| 24 | + | }{ | |
| 25 | + | {name: "plain input passes through", input: "ls\r", want: "ls\r"}, | |
| 26 | + | {name: "focus in only", input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused, changed: true}, | |
| 27 | + | {name: "focus out only", start: domain.TerminalFocusFocused, input: "\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true}, | |
| 28 | + | {name: "last report wins", input: "\x1b[I\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true}, | |
| 29 | + | {name: "keys around a report keep their order", input: "a\x1b[Ib", want: "ab", wantFocus: domain.TerminalFocusFocused, changed: true}, | |
| 30 | + | {name: "arrow keys and csi are untouched", input: "\x1b[A\x1b[1;5C\x1b[<0;1;1M", want: "\x1b[A\x1b[1;5C\x1b[<0;1;1M"}, | |
| 31 | + | {name: "a lone escape is never withheld", input: "\x1b", want: "\x1b"}, | |
| 32 | + | {name: "a split report is forwarded as typed", input: "\x1b[", want: "\x1b["}, | |
| 33 | + | {name: "a repeated state changes nothing", start: domain.TerminalFocusFocused, input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused}, | |
| 34 | + | } | |
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | state := newTerminalFocusState() | |
| 38 | + | state.set(tt.start) | |
| 39 | + | _, changed := state.Watch() | |
| 40 | + | ||
| 41 | + | got := stripTerminalFocusReports(state, []byte(tt.input)) | |
| 42 | + | ||
| 43 | + | require.Equal(t, tt.want, string(got)) | |
| 44 | + | want := tt.wantFocus | |
| 45 | + | if want == domain.TerminalFocusUnknown { | |
| 46 | + | want = tt.start | |
| 47 | + | } | |
| 48 | + | require.Equal(t, want, state.Focus()) | |
| 49 | + | select { | |
| 50 | + | case <-changed: | |
| 51 | + | require.True(t, tt.changed, "no change was expected") | |
| 52 | + | default: | |
| 53 | + | require.False(t, tt.changed, "a change was expected") | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestFocusReporterSendsKnownChangesOnce(t *testing.T) { | |
| 60 | + | state := newTerminalFocusState() | |
| 61 | + | reporter := &focusReporter{state: state} | |
| 62 | + | ||
| 63 | + | _, ok := reporter.next() | |
| 64 | + | require.False(t, ok, "unknown focus is never sent") | |
| 65 | + | ||
| 66 | + | steps := []struct { | |
| 67 | + | focus domain.TerminalFocus | |
| 68 | + | want protocol.TerminalFocus | |
| 69 | + | wantOK bool | |
| 70 | + | }{ | |
| 71 | + | {focus: domain.TerminalFocusFocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}, wantOK: true}, | |
| 72 | + | {focus: domain.TerminalFocusUnfocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}, wantOK: true}, | |
| 73 | + | {focus: domain.TerminalFocusUnfocused}, | |
| 74 | + | } | |
| 75 | + | for _, step := range steps { | |
| 76 | + | wake := reporter.changed | |
| 77 | + | state.set(step.focus) | |
| 78 | + | if step.wantOK { | |
| 79 | + | select { | |
| 80 | + | case <-wake: | |
| 81 | + | default: | |
| 82 | + | t.Fatalf("a change to %s must wake the reporter", step.focus) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | got, ok := reporter.next() | |
| 86 | + | require.Equal(t, step.wantOK, ok) | |
| 87 | + | require.Equal(t, step.want, got) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // TestTerminalInputPumpStripsFocusReports proves focus reports never reach an | |
| 92 | + | // attachment: a read that only carried a report delivers nothing, the focus | |
| 93 | + | // the terminal reported is readable on the foreground, and later keys arrive | |
| 94 | + | // intact. | |
| 95 | + | func TestTerminalInputPumpStripsFocusReports(t *testing.T) { | |
| 96 | + | pr, pw := io.Pipe() | |
| 97 | + | pump := newTerminalInputPump(pr) | |
| 98 | + | pump.start() | |
| 99 | + | defer pump.stop() | |
| 100 | + | defer pw.Close() | |
| 101 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |
| 102 | + | ||
| 103 | + | observed := make(chan []byte, 1) | |
| 104 | + | focus := make(chan domain.TerminalFocus, 1) | |
| 105 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 106 | + | event, ok := fg.Input(ctx) | |
| 107 | + | if !ok { | |
| 108 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no input")} | |
| 109 | + | } | |
| 110 | + | fg.AckInput() | |
| 111 | + | observed <- append([]byte(nil), event.Data...) | |
| 112 | + | focus <- attachmentTerminalFocus(fg).Focus() | |
| 113 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 114 | + | }} | |
| 115 | + | ||
| 116 | + | go func() { | |
| 117 | + | _, _ = pw.Write([]byte("\x1b[O")) | |
| 118 | + | _, _ = pw.Write([]byte("\x1b[Iab")) | |
| 119 | + | }() | |
| 120 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 121 | + | defer cancel() | |
| 122 | + | _, adopted := host.Run(ctx, AttachmentToken{Generation: 1, Attempt: 1}, worker, newWorkerTestStream()) | |
| 123 | + | require.True(t, adopted) | |
| 124 | + | ||
| 125 | + | require.Equal(t, []byte("ab"), <-observed, "the focus-only read is never delivered") | |
| 126 | + | require.Equal(t, domain.TerminalFocusFocused, <-focus) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestNilTerminalFocusStateIsUnknown(t *testing.T) { | |
| 130 | + | var state *TerminalFocusState | |
| 131 | + | focus, changed := state.Watch() | |
| 132 | + | require.Equal(t, domain.TerminalFocusUnknown, focus) | |
| 133 | + | require.Nil(t, changed) | |
| 134 | + | require.Equal(t, domain.TerminalFocusUnknown, state.Focus()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // TestAttachmentDeclaresFocusInHelloThenReportsChanges proves the daemon | |
| 138 | + | // learns a terminal's focus before its first paint: Hello carries the focus | |
| 139 | + | // already known, and each later change follows as TerminalFocus. | |
| 140 | + | func TestAttachmentDeclaresFocusInHelloThenReportsChanges(t *testing.T) { | |
| 141 | + | pump := newTerminalInputPump(blockingReader{}) | |
| 142 | + | pump.focus.set(domain.TerminalFocusUnfocused) | |
| 143 | + | stream := newSessionTestStream() | |
| 144 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |
| 145 | + | worker, err := newSessionAttachmentWorker(sessionTestWorkerConfig(sessionTestRequest(true))) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 148 | + | defer cancel() | |
| 149 | + | go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }() | |
| 150 | + | ||
| 151 | + | hello := awaitHello(t, stream) | |
| 152 | + | require.Equal(t, domain.TerminalFocusUnfocused, hello.TerminalFocus) | |
| 153 | + | require.NoError(t, protocol.ValidateHello(hello)) | |
| 154 | + | ||
| 155 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 156 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 157 | + | pump.focus.set(domain.TerminalFocusFocused) | |
| 158 | + | require.Eventually(t, func() bool { | |
| 159 | + | for _, message := range stream.messages() { | |
| 160 | + | if message == (protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) { | |
| 161 | + | return true | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return false | |
| 165 | + | }, 5*time.Second, time.Millisecond, "a focus change after attach is reported") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // blockingReader is a terminal that never sends input. | |
| 169 | + | type blockingReader struct{} | |
| 170 | + | ||
| 171 | + | func (blockingReader) Read([]byte) (int, error) { select {} } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestStripTerminalFocusReports(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | start domain.TerminalFocus | |||
| 20 | + | input string | |||
| 21 | + | want string | |||
| 22 | + | wantFocus domain.TerminalFocus | |||
| 23 | + | changed bool | |||
| 24 | + | }{ | |||
| 25 | + | {name: "plain input passes through", input: "ls\r", want: "ls\r"}, | |||
| 26 | + | {name: "focus in only", input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused, changed: true}, | |||
| 27 | + | {name: "focus out only", start: domain.TerminalFocusFocused, input: "\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true}, | |||
| 28 | + | {name: "last report wins", input: "\x1b[I\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true}, | |||
| 29 | + | {name: "keys around a report keep their order", input: "a\x1b[Ib", want: "ab", wantFocus: domain.TerminalFocusFocused, changed: true}, | |||
| 30 | + | {name: "arrow keys and csi are untouched", input: "\x1b[A\x1b[1;5C\x1b[<0;1;1M", want: "\x1b[A\x1b[1;5C\x1b[<0;1;1M"}, | |||
| 31 | + | {name: "a lone escape is never withheld", input: "\x1b", want: "\x1b"}, | |||
| 32 | + | {name: "a split report is forwarded as typed", input: "\x1b[", want: "\x1b["}, | |||
| 33 | + | {name: "a repeated state changes nothing", start: domain.TerminalFocusFocused, input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused}, | |||
| 34 | + | } | |||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | state := newTerminalFocusState() | |||
| 38 | + | state.set(tt.start) | |||
| 39 | + | _, changed := state.Watch() | |||
| 40 | + | ||||
| 41 | + | got := stripTerminalFocusReports(state, []byte(tt.input)) | |||
| 42 | + | ||||
| 43 | + | require.Equal(t, tt.want, string(got)) | |||
| 44 | + | want := tt.wantFocus | |||
| 45 | + | if want == domain.TerminalFocusUnknown { | |||
| 46 | + | want = tt.start | |||
| 47 | + | } | |||
| 48 | + | require.Equal(t, want, state.Focus()) | |||
| 49 | + | select { | |||
| 50 | + | case <-changed: | |||
| 51 | + | require.True(t, tt.changed, "no change was expected") | |||
| 52 | + | default: | |||
| 53 | + | require.False(t, tt.changed, "a change was expected") | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestFocusReporterSendsKnownChangesOnce(t *testing.T) { | |||
| 60 | + | state := newTerminalFocusState() | |||
| 61 | + | reporter := &focusReporter{state: state} | |||
| 62 | + | ||||
| 63 | + | _, ok := reporter.next() | |||
| 64 | + | require.False(t, ok, "unknown focus is never sent") | |||
| 65 | + | ||||
| 66 | + | steps := []struct { | |||
| 67 | + | focus domain.TerminalFocus | |||
| 68 | + | want protocol.TerminalFocus | |||
| 69 | + | wantOK bool | |||
| 70 | + | }{ | |||
| 71 | + | {focus: domain.TerminalFocusFocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}, wantOK: true}, | |||
| 72 | + | {focus: domain.TerminalFocusUnfocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}, wantOK: true}, | |||
| 73 | + | {focus: domain.TerminalFocusUnfocused}, | |||
| 74 | + | } | |||
| 75 | + | for _, step := range steps { | |||
| 76 | + | wake := reporter.changed | |||
| 77 | + | state.set(step.focus) | |||
| 78 | + | if step.wantOK { | |||
| 79 | + | select { | |||
| 80 | + | case <-wake: | |||
| 81 | + | default: | |||
| 82 | + | t.Fatalf("a change to %s must wake the reporter", step.focus) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | got, ok := reporter.next() | |||
| 86 | + | require.Equal(t, step.wantOK, ok) | |||
| 87 | + | require.Equal(t, step.want, got) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // TestTerminalInputPumpStripsFocusReports proves focus reports never reach an | |||
| 92 | + | // attachment: a read that only carried a report delivers nothing, the focus | |||
| 93 | + | // the terminal reported is readable on the foreground, and later keys arrive | |||
| 94 | + | // intact. | |||
| 95 | + | func TestTerminalInputPumpStripsFocusReports(t *testing.T) { | |||
| 96 | + | pr, pw := io.Pipe() | |||
| 97 | + | pump := newTerminalInputPump(pr) | |||
| 98 | + | pump.start() | |||
| 99 | + | defer pump.stop() | |||
| 100 | + | defer pw.Close() | |||
| 101 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |||
| 102 | + | ||||
| 103 | + | observed := make(chan []byte, 1) | |||
| 104 | + | focus := make(chan domain.TerminalFocus, 1) | |||
| 105 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 106 | + | event, ok := fg.Input(ctx) | |||
| 107 | + | if !ok { | |||
| 108 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no input")} | |||
| 109 | + | } | |||
| 110 | + | fg.AckInput() | |||
| 111 | + | observed <- append([]byte(nil), event.Data...) | |||
| 112 | + | focus <- attachmentTerminalFocus(fg).Focus() | |||
| 113 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 114 | + | }} | |||
| 115 | + | ||||
| 116 | + | go func() { | |||
| 117 | + | _, _ = pw.Write([]byte("\x1b[O")) | |||
| 118 | + | _, _ = pw.Write([]byte("\x1b[Iab")) | |||
| 119 | + | }() | |||
| 120 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 121 | + | defer cancel() | |||
| 122 | + | _, adopted := host.Run(ctx, AttachmentToken{Generation: 1, Attempt: 1}, worker, newWorkerTestStream()) | |||
| 123 | + | require.True(t, adopted) | |||
| 124 | + | ||||
| 125 | + | require.Equal(t, []byte("ab"), <-observed, "the focus-only read is never delivered") | |||
| 126 | + | require.Equal(t, domain.TerminalFocusFocused, <-focus) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestNilTerminalFocusStateIsUnknown(t *testing.T) { | |||
| 130 | + | var state *TerminalFocusState | |||
| 131 | + | focus, changed := state.Watch() | |||
| 132 | + | require.Equal(t, domain.TerminalFocusUnknown, focus) | |||
| 133 | + | require.Nil(t, changed) | |||
| 134 | + | require.Equal(t, domain.TerminalFocusUnknown, state.Focus()) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // TestAttachmentDeclaresFocusInHelloThenReportsChanges proves the daemon | |||
| 138 | + | // learns a terminal's focus before its first paint: Hello carries the focus | |||
| 139 | + | // already known, and each later change follows as TerminalFocus. | |||
| 140 | + | func TestAttachmentDeclaresFocusInHelloThenReportsChanges(t *testing.T) { | |||
| 141 | + | pump := newTerminalInputPump(blockingReader{}) | |||
| 142 | + | pump.focus.set(domain.TerminalFocusUnfocused) | |||
| 143 | + | stream := newSessionTestStream() | |||
| 144 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |||
| 145 | + | worker, err := newSessionAttachmentWorker(sessionTestWorkerConfig(sessionTestRequest(true))) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 148 | + | defer cancel() | |||
| 149 | + | go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }() | |||
| 150 | + | ||||
| 151 | + | hello := awaitHello(t, stream) | |||
| 152 | + | require.Equal(t, domain.TerminalFocusUnfocused, hello.TerminalFocus) | |||
| 153 | + | require.NoError(t, protocol.ValidateHello(hello)) | |||
| 154 | + | ||||
| 155 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 156 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 157 | + | pump.focus.set(domain.TerminalFocusFocused) | |||
| 158 | + | require.Eventually(t, func() bool { | |||
| 159 | + | for _, message := range stream.messages() { | |||
| 160 | + | if message == (protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) { | |||
| 161 | + | return true | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return false | |||
| 165 | + | }, 5*time.Second, time.Millisecond, "a focus change after attach is reported") | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // blockingReader is a terminal that never sends input. | |||
| 169 | + | type blockingReader struct{} | |||
| 170 | + | ||||
| 171 | + | func (blockingReader) Read([]byte) (int, error) { select {} } | |||
M internal/usecase/client/terminal_input_pump.go +11 -2
| @@ -48,7 +48,10 @@ type terminalAutomationRequest struct { | |||
| 48 | 48 | // after Run exits; suspend drops any result received while inactive and never | |
| 49 | 49 | // closes caller-owned stdin. Reconnects and later runs never add another reader. | |
| 50 | 50 | type terminalInputPump struct { | |
| 51 | - | in io.Reader | |
| 51 | + | in io.Reader | |
| 52 | + | // focus is the terminal focus decoded from this reader. The pump strips | |
| 53 | + | // focus reports before any consumer sees the bytes. | |
| 54 | + | focus *TerminalFocusState | |
| 52 | 55 | done chan struct{} | |
| 53 | 56 | automation chan terminalAutomationRequest | |
| 54 | 57 | ||
| @@ -87,6 +90,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 87 | 90 | space <- struct{}{} | |
| 88 | 91 | return &terminalInputPump{ | |
| 89 | 92 | in: in, | |
| 93 | + | focus: newTerminalFocusState(), | |
| 90 | 94 | done: make(chan struct{}), | |
| 91 | 95 | automation: make(chan terminalAutomationRequest), | |
| 92 | 96 | activation: 1, | |
| @@ -339,7 +343,12 @@ func (p *terminalInputPump) start() { | |||
| 339 | 343 | n, err := p.in.Read(buf) | |
| 340 | 344 | result := terminalReadResult{err: err} | |
| 341 | 345 | if n > 0 { | |
| 342 | - | result.data = append([]byte(nil), buf[:n]...) | |
| 346 | + | if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 { | |
| 347 | + | result.data = append([]byte(nil), data...) | |
| 348 | + | } else if err == nil { | |
| 349 | + | // A read that only carried focus reports delivers nothing. | |
| 350 | + | continue | |
| 351 | + | } | |
| 343 | 352 | } | |
| 344 | 353 | if !p.enqueue(result, activation) { | |
| 345 | 354 | if p.isClosed() { | |
| @@ -48,7 +48,10 @@ type terminalAutomationRequest struct { | |||||
| 48 | // after Run exits; suspend drops any result received while inactive and never | 48 | // after Run exits; suspend drops any result received while inactive and never | ||
| 49 | // closes caller-owned stdin. Reconnects and later runs never add another reader. | 49 | // closes caller-owned stdin. Reconnects and later runs never add another reader. | ||
| 50 | type terminalInputPump struct { | 50 | type terminalInputPump struct { | ||
| 51 | - | in io.Reader | 51 | + | in io.Reader |
| 52 | + | // focus is the terminal focus decoded from this reader. The pump strips | |||
| 53 | + | // focus reports before any consumer sees the bytes. | |||
| 54 | + | focus *TerminalFocusState | |||
| 52 | done chan struct{} | 55 | done chan struct{} | ||
| 53 | automation chan terminalAutomationRequest | 56 | automation chan terminalAutomationRequest | ||
| 54 | 57 | ||||
| @@ -87,6 +90,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||||
| 87 | space <- struct{}{} | 90 | space <- struct{}{} | ||
| 88 | return &terminalInputPump{ | 91 | return &terminalInputPump{ | ||
| 89 | in: in, | 92 | in: in, | ||
| 93 | + | focus: newTerminalFocusState(), | |||
| 90 | done: make(chan struct{}), | 94 | done: make(chan struct{}), | ||
| 91 | automation: make(chan terminalAutomationRequest), | 95 | automation: make(chan terminalAutomationRequest), | ||
| 92 | activation: 1, | 96 | activation: 1, | ||
| @@ -339,7 +343,12 @@ func (p *terminalInputPump) start() { | |||||
| 339 | n, err := p.in.Read(buf) | 343 | n, err := p.in.Read(buf) | ||
| 340 | result := terminalReadResult{err: err} | 344 | result := terminalReadResult{err: err} | ||
| 341 | if n > 0 { | 345 | if n > 0 { | ||
| 342 | - | result.data = append([]byte(nil), buf[:n]...) | 346 | + | if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 { |
| 347 | + | result.data = append([]byte(nil), data...) | |||
| 348 | + | } else if err == nil { | |||
| 349 | + | // A read that only carried focus reports delivers nothing. | |||
| 350 | + | continue | |||
| 351 | + | } | |||
| 343 | } | 352 | } | ||
| 344 | if !p.enqueue(result, activation) { | 353 | if !p.enqueue(result, activation) { | ||
| 345 | if p.isClosed() { | 354 | if p.isClosed() { | ||
M internal/usecase/daemon/attention.go +5 -2
| @@ -31,7 +31,7 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 31 | 31 | tb.attention = true | |
| 32 | 32 | for _, attachment := range sess.snapshotAttachmentsLocked() { | |
| 33 | 33 | view := attachment.viewSnapshot() | |
| 34 | - | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 34 | + | if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() { | |
| 35 | 35 | tb.attentionVisiblePaint = true | |
| 36 | 36 | break | |
| 37 | 37 | } | |
| @@ -232,8 +232,11 @@ func (d *Daemon) repaintAllAttachedClients() { | |||
| 232 | 232 | } | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | + | // ackAttention clears the bell of the tab ac shows. A client whose terminal | |
| 236 | + | // window reported losing focus is not looked at, so it never clears a bell: | |
| 237 | + | // the bell stays for every client until one that may be seen shows the tab. | |
| 235 | 238 | func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { | |
| 236 | - | if tb == nil || ac == nil { | |
| 239 | + | if tb == nil || ac == nil || !ac.terminalFocus().MaySee() { | |
| 237 | 240 | return false | |
| 238 | 241 | } | |
| 239 | 242 | s.mu.Lock() | |
| @@ -31,7 +31,7 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||||
| 31 | tb.attention = true | 31 | tb.attention = true | ||
| 32 | for _, attachment := range sess.snapshotAttachmentsLocked() { | 32 | for _, attachment := range sess.snapshotAttachmentsLocked() { | ||
| 33 | view := attachment.viewSnapshot() | 33 | view := attachment.viewSnapshot() | ||
| 34 | - | if domain.TabStableID(tb.stableID) == view.tabID { | 34 | + | if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() { |
| 35 | tb.attentionVisiblePaint = true | 35 | tb.attentionVisiblePaint = true | ||
| 36 | break | 36 | break | ||
| 37 | } | 37 | } | ||
| @@ -232,8 +232,11 @@ func (d *Daemon) repaintAllAttachedClients() { | |||||
| 232 | } | 232 | } | ||
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | + | // ackAttention clears the bell of the tab ac shows. A client whose terminal | |||
| 236 | + | // window reported losing focus is not looked at, so it never clears a bell: | |||
| 237 | + | // the bell stays for every client until one that may be seen shows the tab. | |||
| 235 | func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { | 238 | func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { | ||
| 236 | - | if tb == nil || ac == nil { | 239 | + | if tb == nil || ac == nil || !ac.terminalFocus().MaySee() { |
| 237 | return false | 240 | return false | ||
| 238 | } | 241 | } | ||
| 239 | s.mu.Lock() | 242 | s.mu.Lock() | ||
M internal/usecase/daemon/client.go +6 -0
| @@ -69,6 +69,10 @@ type attachedClient struct { | |||
| 69 | 69 | resumeClaimToken uint64 | |
| 70 | 70 | parked bool | |
| 71 | 71 | echoAck atomic.Uint64 | |
| 72 | + | // focus holds the domain.TerminalFocus the client last reported. It is an | |
| 73 | + | // atomic so any daemon path reads it without taking a lock; see | |
| 74 | + | // terminalFocus. | |
| 75 | + | focus atomic.Uint32 | |
| 72 | 76 | // prepareFailureFallback prevents a direct fallback paint from recursively | |
| 73 | 77 | // reporting the same failed prepare through its notice repaint. It is only | |
| 74 | 78 | // needed while no render coordinator is installed. | |
| @@ -636,6 +640,7 @@ type attachClientOptions struct { | |||
| 636 | 640 | terminalCapabilities terminalcap.Capabilities | |
| 637 | 641 | capabilitiesSet bool | |
| 638 | 642 | navigationCapabilities protocol.NavigationCapabilities | |
| 643 | + | terminalFocus domain.TerminalFocus | |
| 639 | 644 | } | |
| 640 | 645 | ||
| 641 | 646 | func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | |
| @@ -717,6 +722,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 717 | 722 | resumeCapable: opts.resumeCapable, | |
| 718 | 723 | resumeToken: resumeToken, | |
| 719 | 724 | } | |
| 725 | + | ac.setTerminalFocus(opts.terminalFocus) | |
| 720 | 726 | output.attachment = ac | |
| 721 | 727 | ac.initOverlays() | |
| 722 | 728 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| @@ -69,6 +69,10 @@ type attachedClient struct { | |||||
| 69 | resumeClaimToken uint64 | 69 | resumeClaimToken uint64 | ||
| 70 | parked bool | 70 | parked bool | ||
| 71 | echoAck atomic.Uint64 | 71 | echoAck atomic.Uint64 | ||
| 72 | + | // focus holds the domain.TerminalFocus the client last reported. It is an | |||
| 73 | + | // atomic so any daemon path reads it without taking a lock; see | |||
| 74 | + | // terminalFocus. | |||
| 75 | + | focus atomic.Uint32 | |||
| 72 | // prepareFailureFallback prevents a direct fallback paint from recursively | 76 | // prepareFailureFallback prevents a direct fallback paint from recursively | ||
| 73 | // reporting the same failed prepare through its notice repaint. It is only | 77 | // reporting the same failed prepare through its notice repaint. It is only | ||
| 74 | // needed while no render coordinator is installed. | 78 | // needed while no render coordinator is installed. | ||
| @@ -636,6 +640,7 @@ type attachClientOptions struct { | |||||
| 636 | terminalCapabilities terminalcap.Capabilities | 640 | terminalCapabilities terminalcap.Capabilities | ||
| 637 | capabilitiesSet bool | 641 | capabilitiesSet bool | ||
| 638 | navigationCapabilities protocol.NavigationCapabilities | 642 | navigationCapabilities protocol.NavigationCapabilities | ||
| 643 | + | terminalFocus domain.TerminalFocus | |||
| 639 | } | 644 | } | ||
| 640 | 645 | ||||
| 641 | func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | 646 | func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | ||
| @@ -717,6 +722,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||||
| 717 | resumeCapable: opts.resumeCapable, | 722 | resumeCapable: opts.resumeCapable, | ||
| 718 | resumeToken: resumeToken, | 723 | resumeToken: resumeToken, | ||
| 719 | } | 724 | } | ||
| 725 | + | ac.setTerminalFocus(opts.terminalFocus) | |||
| 720 | output.attachment = ac | 726 | output.attachment = ac | ||
| 721 | ac.initOverlays() | 727 | ac.initOverlays() | ||
| 722 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | 728 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | ||
M internal/usecase/daemon/client_frame_routing.go +2 -0
| @@ -186,6 +186,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||
| 186 | 186 | d.switchSamePeerForAttachment(effect, message) | |
| 187 | 187 | case protocol.SelectTab: | |
| 188 | 188 | d.selectTabForAttachment(effect, message) | |
| 189 | + | case protocol.TerminalFocus: | |
| 190 | + | d.applyTerminalFocusForAttachment(effect, message) | |
| 189 | 191 | case protocol.RecentRouteSnapshot: | |
| 190 | 192 | replayIdentity := effect.ac.setRouteSnapshot(message) | |
| 191 | 193 | d.pokeAttentionTicker() | |
| @@ -186,6 +186,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||||
| 186 | d.switchSamePeerForAttachment(effect, message) | 186 | d.switchSamePeerForAttachment(effect, message) | ||
| 187 | case protocol.SelectTab: | 187 | case protocol.SelectTab: | ||
| 188 | d.selectTabForAttachment(effect, message) | 188 | d.selectTabForAttachment(effect, message) | ||
| 189 | + | case protocol.TerminalFocus: | |||
| 190 | + | d.applyTerminalFocusForAttachment(effect, message) | |||
| 189 | case protocol.RecentRouteSnapshot: | 191 | case protocol.RecentRouteSnapshot: | ||
| 190 | replayIdentity := effect.ac.setRouteSnapshot(message) | 192 | replayIdentity := effect.ac.setRouteSnapshot(message) | ||
| 191 | d.pokeAttentionTicker() | 193 | d.pokeAttentionTicker() | ||
M internal/usecase/daemon/daemon.go +1 -0
| @@ -1506,6 +1506,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai | |||
| 1506 | 1506 | resumeCapable: true, | |
| 1507 | 1507 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1508 | 1508 | navigationCapabilities: h.NavigationCapabilities, | |
| 1509 | + | terminalFocus: h.TerminalFocus, | |
| 1509 | 1510 | terminalCapabilities: terminalCapabilities, | |
| 1510 | 1511 | capabilitiesSet: true, | |
| 1511 | 1512 | } | |
| @@ -1506,6 +1506,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai | |||||
| 1506 | resumeCapable: true, | 1506 | resumeCapable: true, | ||
| 1507 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | 1507 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | ||
| 1508 | navigationCapabilities: h.NavigationCapabilities, | 1508 | navigationCapabilities: h.NavigationCapabilities, | ||
| 1509 | + | terminalFocus: h.TerminalFocus, | |||
| 1509 | terminalCapabilities: terminalCapabilities, | 1510 | terminalCapabilities: terminalCapabilities, | ||
| 1510 | capabilitiesSet: true, | 1511 | capabilitiesSet: true, | ||
| 1511 | } | 1512 | } | ||
M internal/usecase/daemon/resume.go +2 -0
| @@ -667,6 +667,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||
| 667 | 667 | // direct-terminal capability before resumed capture or first paint observes | |
| 668 | 668 | // the replacement transport. | |
| 669 | 669 | d.reconfigureAttachmentOutput(sess, ac, h) | |
| 670 | + | // The replacement terminal's focus, too, is declared before first paint. | |
| 671 | + | ac.setTerminalFocus(h.TerminalFocus) | |
| 670 | 672 | geometry := h.Geometry() | |
| 671 | 673 | if geometry.Size != sz { | |
| 672 | 674 | geometry = domain.Geometry{Size: sz} | |
| @@ -667,6 +667,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||||
| 667 | // direct-terminal capability before resumed capture or first paint observes | 667 | // direct-terminal capability before resumed capture or first paint observes | ||
| 668 | // the replacement transport. | 668 | // the replacement transport. | ||
| 669 | d.reconfigureAttachmentOutput(sess, ac, h) | 669 | d.reconfigureAttachmentOutput(sess, ac, h) | ||
| 670 | + | // The replacement terminal's focus, too, is declared before first paint. | |||
| 671 | + | ac.setTerminalFocus(h.TerminalFocus) | |||
| 670 | geometry := h.Geometry() | 672 | geometry := h.Geometry() | ||
| 671 | if geometry.Size != sz { | 673 | if geometry.Size != sz { | ||
| 672 | geometry = domain.Geometry{Size: sz} | 674 | geometry = domain.Geometry{Size: sz} | ||
M internal/usecase/daemon/status.go +4 -1
| @@ -206,6 +206,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||
| 206 | 206 | name := formatRecentRouteName(presentation) | |
| 207 | 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 208 | 208 | activeIndex := 0 | |
| 209 | + | // An unfocused client leaves the bell of its own tab pending, so it shows | |
| 210 | + | // it like a background tab's bell. | |
| 211 | + | seen := ac.terminalFocus().MaySee() | |
| 209 | 212 | if ac != nil { | |
| 210 | 213 | view := ac.viewSnapshot() | |
| 211 | 214 | for i, tb := range s.tabs { | |
| @@ -218,7 +221,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||
| 218 | 221 | for i, tb := range s.tabs { | |
| 219 | 222 | name := tabDisplayName(tb, i) | |
| 220 | 223 | active := i == activeIndex | |
| 221 | - | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 224 | + | attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint) | |
| 222 | 225 | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} | |
| 223 | 226 | } | |
| 224 | 227 | return snap | |
| @@ -206,6 +206,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||||
| 206 | name := formatRecentRouteName(presentation) | 206 | name := formatRecentRouteName(presentation) | ||
| 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | 207 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | ||
| 208 | activeIndex := 0 | 208 | activeIndex := 0 | ||
| 209 | + | // An unfocused client leaves the bell of its own tab pending, so it shows | |||
| 210 | + | // it like a background tab's bell. | |||
| 211 | + | seen := ac.terminalFocus().MaySee() | |||
| 209 | if ac != nil { | 212 | if ac != nil { | ||
| 210 | view := ac.viewSnapshot() | 213 | view := ac.viewSnapshot() | ||
| 211 | for i, tb := range s.tabs { | 214 | for i, tb := range s.tabs { | ||
| @@ -218,7 +221,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||||
| 218 | for i, tb := range s.tabs { | 221 | for i, tb := range s.tabs { | ||
| 219 | name := tabDisplayName(tb, i) | 222 | name := tabDisplayName(tb, i) | ||
| 220 | active := i == activeIndex | 223 | active := i == activeIndex | ||
| 221 | - | attention := tb.attention && (!active || tb.attentionVisiblePaint) | 224 | + | attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint) |
| 222 | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} | 225 | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} | ||
| 223 | } | 226 | } | ||
| 224 | return snap | 227 | return snap | ||
A internal/usecase/daemon/terminal_focus.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Terminal focus. | |
| 9 | + | // | |
| 10 | + | // Each attachment carries the focus its client's terminal window last | |
| 11 | + | // reported. It is a lock-free read, so any daemon feature can ask whether a | |
| 12 | + | // user may be looking at an attachment. An attachment that never reports keeps | |
| 13 | + | // unknown focus, which counts as possibly seen, so terminals without focus | |
| 14 | + | // reporting behave exactly as before. | |
| 15 | + | // | |
| 16 | + | // Attention uses it today: only an attachment that may be seen acknowledges a | |
| 17 | + | // bell, so a vev window left open on another workspace no longer clears it for | |
| 18 | + | // every other client. When that window gains focus again, the tab it shows is | |
| 19 | + | // acknowledged by its next paint. | |
| 20 | + | ||
| 21 | + | // terminalFocus returns the focus the client last reported. | |
| 22 | + | func (ac *attachedClient) terminalFocus() domain.TerminalFocus { | |
| 23 | + | if ac == nil { | |
| 24 | + | return domain.TerminalFocusUnknown | |
| 25 | + | } | |
| 26 | + | return domain.TerminalFocus(ac.focus.Load()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // setTerminalFocus records a reported focus and reports whether it changed. | |
| 30 | + | func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool { | |
| 31 | + | return domain.TerminalFocus(ac.focus.Swap(uint32(focus))) != focus | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // applyTerminalFocusForAttachment records a focus report. Gaining focus | |
| 35 | + | // repaints the attachment, whose paint acknowledges the visible tab's bell, | |
| 36 | + | // and the bars of every client, whose bells may clear with it. | |
| 37 | + | func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) { | |
| 38 | + | if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | if !message.Focus.MaySee() { | |
| 42 | + | effect.ac.setTerminalFocus(message.Focus) | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | // Like a bell rung on a seen tab, the bell a focused window finds on its | |
| 46 | + | // tab is drawn for one pulse before a paint acknowledges it. Mark it before | |
| 47 | + | // the focus lets any paint acknowledge. | |
| 48 | + | effect.sess.markVisibleAttention(effect.ac) | |
| 49 | + | effect.ac.setTerminalFocus(message.Focus) | |
| 50 | + | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // markVisibleAttention asks for one visible bell pulse on the tab ac shows, | |
| 54 | + | // when that tab is ringing. | |
| 55 | + | func (s *session) markVisibleAttention(ac *attachedClient) { | |
| 56 | + | view := ac.viewSnapshot() | |
| 57 | + | s.mu.Lock() | |
| 58 | + | defer s.mu.Unlock() | |
| 59 | + | for _, tb := range s.tabs { | |
| 60 | + | if tb != nil && tb.attention && domain.TabStableID(tb.stableID) == view.tabID { | |
| 61 | + | tb.attentionVisiblePaint = true | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/protocol" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Terminal focus. | |||
| 9 | + | // | |||
| 10 | + | // Each attachment carries the focus its client's terminal window last | |||
| 11 | + | // reported. It is a lock-free read, so any daemon feature can ask whether a | |||
| 12 | + | // user may be looking at an attachment. An attachment that never reports keeps | |||
| 13 | + | // unknown focus, which counts as possibly seen, so terminals without focus | |||
| 14 | + | // reporting behave exactly as before. | |||
| 15 | + | // | |||
| 16 | + | // Attention uses it today: only an attachment that may be seen acknowledges a | |||
| 17 | + | // bell, so a vev window left open on another workspace no longer clears it for | |||
| 18 | + | // every other client. When that window gains focus again, the tab it shows is | |||
| 19 | + | // acknowledged by its next paint. | |||
| 20 | + | ||||
| 21 | + | // terminalFocus returns the focus the client last reported. | |||
| 22 | + | func (ac *attachedClient) terminalFocus() domain.TerminalFocus { | |||
| 23 | + | if ac == nil { | |||
| 24 | + | return domain.TerminalFocusUnknown | |||
| 25 | + | } | |||
| 26 | + | return domain.TerminalFocus(ac.focus.Load()) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // setTerminalFocus records a reported focus and reports whether it changed. | |||
| 30 | + | func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool { | |||
| 31 | + | return domain.TerminalFocus(ac.focus.Swap(uint32(focus))) != focus | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // applyTerminalFocusForAttachment records a focus report. Gaining focus | |||
| 35 | + | // repaints the attachment, whose paint acknowledges the visible tab's bell, | |||
| 36 | + | // and the bars of every client, whose bells may clear with it. | |||
| 37 | + | func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) { | |||
| 38 | + | if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | if !message.Focus.MaySee() { | |||
| 42 | + | effect.ac.setTerminalFocus(message.Focus) | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | // Like a bell rung on a seen tab, the bell a focused window finds on its | |||
| 46 | + | // tab is drawn for one pulse before a paint acknowledges it. Mark it before | |||
| 47 | + | // the focus lets any paint acknowledge. | |||
| 48 | + | effect.sess.markVisibleAttention(effect.ac) | |||
| 49 | + | effect.ac.setTerminalFocus(message.Focus) | |||
| 50 | + | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // markVisibleAttention asks for one visible bell pulse on the tab ac shows, | |||
| 54 | + | // when that tab is ringing. | |||
| 55 | + | func (s *session) markVisibleAttention(ac *attachedClient) { | |||
| 56 | + | view := ac.viewSnapshot() | |||
| 57 | + | s.mu.Lock() | |||
| 58 | + | defer s.mu.Unlock() | |||
| 59 | + | for _, tb := range s.tabs { | |||
| 60 | + | if tb != nil && tb.attention && domain.TabStableID(tb.stableID) == view.tabID { | |||
| 61 | + | tb.attentionVisiblePaint = true | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | } | |||
A internal/usecase/daemon/terminal_focus_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a | |
| 14 | + | // client whose terminal window lost focus shows the bell of its own tab but | |
| 15 | + | // never clears it, while a focused client, or one whose terminal never | |
| 16 | + | // reports focus, clears it on its visible paint as before. | |
| 17 | + | func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) { | |
| 18 | + | tests := []struct { | |
| 19 | + | name string | |
| 20 | + | focus domain.TerminalFocus | |
| 21 | + | wantAcked bool | |
| 22 | + | }{ | |
| 23 | + | {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, | |
| 24 | + | {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, | |
| 25 | + | {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, | |
| 26 | + | } | |
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 30 | + | defer releases[0]() | |
| 31 | + | selectTestAttachmentTab(sess, 0) | |
| 32 | + | ac.setTerminalFocus(tt.focus) | |
| 33 | + | d.noteAttention(sess, sess.tabs[0]) | |
| 34 | + | ||
| 35 | + | d.setAttentionFrame(1) | |
| 36 | + | d.paint(sess, ac, true, nil) | |
| 37 | + | require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell") | |
| 38 | + | ||
| 39 | + | sess.mu.Lock() | |
| 40 | + | defer sess.mu.Unlock() | |
| 41 | + | require.Equal(t, !tt.wantAcked, sess.tabs[0].attention) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a | |
| 47 | + | // hidden client left pending clears once that client's window is focused and | |
| 48 | + | // paints again. | |
| 49 | + | func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) { | |
| 50 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 51 | + | defer releases[0]() | |
| 52 | + | selectTestAttachmentTab(sess, 0) | |
| 53 | + | ac.setTerminalFocus(domain.TerminalFocusUnfocused) | |
| 54 | + | d.noteAttention(sess, sess.tabs[0]) | |
| 55 | + | d.setAttentionFrame(1) | |
| 56 | + | d.paint(sess, ac, true, nil) | |
| 57 | + | mustOutputData(t, sends) | |
| 58 | + | require.True(t, sess.anyAttention(), "hidden client keeps the bell") | |
| 59 | + | ||
| 60 | + | require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused)) | |
| 61 | + | require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change") | |
| 62 | + | d.paint(sess, ac, true, nil) | |
| 63 | + | mustOutputData(t, sends) | |
| 64 | + | require.False(t, sess.anyAttention(), "focused client clears the bell") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in | |
| 68 | + | // Hello is the attachment's focus from its first paint, so a hidden window | |
| 69 | + | // that opens an attachment never clears the bell of the tab it lands on. | |
| 70 | + | func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) { | |
| 71 | + | tests := []struct { | |
| 72 | + | name string | |
| 73 | + | focus domain.TerminalFocus | |
| 74 | + | wantAcked bool | |
| 75 | + | }{ | |
| 76 | + | {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, | |
| 77 | + | {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, | |
| 78 | + | {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, | |
| 79 | + | } | |
| 80 | + | for _, tt := range tests { | |
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |
| 82 | + | d, sess, first, _, releases := newManualTabSession(t, 1) | |
| 83 | + | defer releases[0]() | |
| 84 | + | first.setTerminalFocus(domain.TerminalFocusUnfocused) | |
| 85 | + | d.noteAttention(sess, sess.tabs[0]) | |
| 86 | + | ||
| 87 | + | tr, sends := newCapturingTransport(t) | |
| 88 | + | peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus}) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Equal(t, tt.focus, peer.terminalFocus()) | |
| 91 | + | require.True(t, sess.switchAttachmentTab(peer, 0)) | |
| 92 | + | d.setAttentionFrame(1) | |
| 93 | + | d.paint(sess, peer, true, nil) | |
| 94 | + | mustOutputData(t, sends) | |
| 95 | + | require.Equal(t, !tt.wantAcked, sess.anyAttention()) | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report: | |
| 101 | + | // only a gain of focus repaints, and the bell a focused window finds on its | |
| 102 | + | // tab is drawn for one pulse before a later paint acknowledges it. | |
| 103 | + | func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { | |
| 104 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 105 | + | defer releases[0]() | |
| 106 | + | selectTestAttachmentTab(sess, 0) | |
| 107 | + | effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}} | |
| 108 | + | ||
| 109 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}) | |
| 110 | + | require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus()) | |
| 111 | + | require.Empty(t, sends, "losing focus does not repaint") | |
| 112 | + | d.noteAttention(sess, sess.tabs[0]) | |
| 113 | + | ||
| 114 | + | d.setAttentionFrame(0) | |
| 115 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | |
| 116 | + | require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus()) | |
| 117 | + | mustOutputData(t, sends) | |
| 118 | + | require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame") | |
| 119 | + | ||
| 120 | + | d.setAttentionFrame(1) | |
| 121 | + | d.paint(sess, ac, false, nil) | |
| 122 | + | require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell") | |
| 123 | + | require.False(t, sess.anyAttention(), "the drawn bell is acknowledged") | |
| 124 | + | ||
| 125 | + | // The acknowledgement repaints every client; drain those frames. | |
| 126 | + | for len(sends) > 0 { | |
| 127 | + | <-sends | |
| 128 | + | } | |
| 129 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | |
| 130 | + | require.Empty(t, sends, "an unchanged focus does not repaint") | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a | |||
| 14 | + | // client whose terminal window lost focus shows the bell of its own tab but | |||
| 15 | + | // never clears it, while a focused client, or one whose terminal never | |||
| 16 | + | // reports focus, clears it on its visible paint as before. | |||
| 17 | + | func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | focus domain.TerminalFocus | |||
| 21 | + | wantAcked bool | |||
| 22 | + | }{ | |||
| 23 | + | {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, | |||
| 24 | + | {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, | |||
| 25 | + | {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 30 | + | defer releases[0]() | |||
| 31 | + | selectTestAttachmentTab(sess, 0) | |||
| 32 | + | ac.setTerminalFocus(tt.focus) | |||
| 33 | + | d.noteAttention(sess, sess.tabs[0]) | |||
| 34 | + | ||||
| 35 | + | d.setAttentionFrame(1) | |||
| 36 | + | d.paint(sess, ac, true, nil) | |||
| 37 | + | require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell") | |||
| 38 | + | ||||
| 39 | + | sess.mu.Lock() | |||
| 40 | + | defer sess.mu.Unlock() | |||
| 41 | + | require.Equal(t, !tt.wantAcked, sess.tabs[0].attention) | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a | |||
| 47 | + | // hidden client left pending clears once that client's window is focused and | |||
| 48 | + | // paints again. | |||
| 49 | + | func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) { | |||
| 50 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 51 | + | defer releases[0]() | |||
| 52 | + | selectTestAttachmentTab(sess, 0) | |||
| 53 | + | ac.setTerminalFocus(domain.TerminalFocusUnfocused) | |||
| 54 | + | d.noteAttention(sess, sess.tabs[0]) | |||
| 55 | + | d.setAttentionFrame(1) | |||
| 56 | + | d.paint(sess, ac, true, nil) | |||
| 57 | + | mustOutputData(t, sends) | |||
| 58 | + | require.True(t, sess.anyAttention(), "hidden client keeps the bell") | |||
| 59 | + | ||||
| 60 | + | require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused)) | |||
| 61 | + | require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change") | |||
| 62 | + | d.paint(sess, ac, true, nil) | |||
| 63 | + | mustOutputData(t, sends) | |||
| 64 | + | require.False(t, sess.anyAttention(), "focused client clears the bell") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in | |||
| 68 | + | // Hello is the attachment's focus from its first paint, so a hidden window | |||
| 69 | + | // that opens an attachment never clears the bell of the tab it lands on. | |||
| 70 | + | func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) { | |||
| 71 | + | tests := []struct { | |||
| 72 | + | name string | |||
| 73 | + | focus domain.TerminalFocus | |||
| 74 | + | wantAcked bool | |||
| 75 | + | }{ | |||
| 76 | + | {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, | |||
| 77 | + | {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, | |||
| 78 | + | {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, | |||
| 79 | + | } | |||
| 80 | + | for _, tt := range tests { | |||
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 82 | + | d, sess, first, _, releases := newManualTabSession(t, 1) | |||
| 83 | + | defer releases[0]() | |||
| 84 | + | first.setTerminalFocus(domain.TerminalFocusUnfocused) | |||
| 85 | + | d.noteAttention(sess, sess.tabs[0]) | |||
| 86 | + | ||||
| 87 | + | tr, sends := newCapturingTransport(t) | |||
| 88 | + | peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus}) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.Equal(t, tt.focus, peer.terminalFocus()) | |||
| 91 | + | require.True(t, sess.switchAttachmentTab(peer, 0)) | |||
| 92 | + | d.setAttentionFrame(1) | |||
| 93 | + | d.paint(sess, peer, true, nil) | |||
| 94 | + | mustOutputData(t, sends) | |||
| 95 | + | require.Equal(t, !tt.wantAcked, sess.anyAttention()) | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report: | |||
| 101 | + | // only a gain of focus repaints, and the bell a focused window finds on its | |||
| 102 | + | // tab is drawn for one pulse before a later paint acknowledges it. | |||
| 103 | + | func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { | |||
| 104 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 105 | + | defer releases[0]() | |||
| 106 | + | selectTestAttachmentTab(sess, 0) | |||
| 107 | + | effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}} | |||
| 108 | + | ||||
| 109 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}) | |||
| 110 | + | require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus()) | |||
| 111 | + | require.Empty(t, sends, "losing focus does not repaint") | |||
| 112 | + | d.noteAttention(sess, sess.tabs[0]) | |||
| 113 | + | ||||
| 114 | + | d.setAttentionFrame(0) | |||
| 115 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | |||
| 116 | + | require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus()) | |||
| 117 | + | mustOutputData(t, sends) | |||
| 118 | + | require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame") | |||
| 119 | + | ||||
| 120 | + | d.setAttentionFrame(1) | |||
| 121 | + | d.paint(sess, ac, false, nil) | |||
| 122 | + | require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell") | |||
| 123 | + | require.False(t, sess.anyAttention(), "the drawn bell is acknowledged") | |||
| 124 | + | ||||
| 125 | + | // The acknowledgement repaints every client; drain those frames. | |||
| 126 | + | for len(sends) > 0 { | |||
| 127 | + | <-sends | |||
| 128 | + | } | |||
| 129 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | |||
| 130 | + | require.Empty(t, sends, "an unchanged focus does not repaint") | |||
| 131 | + | } | |||