feat(client): expose the picker composition seam
A composition outside this package could build the supervisor and subscribe to broker publications, but could not hand it a picker: the picker contract is an unexported interface whose methods use unexported types, and the only implementation was built by an unexported constructor. Without that value the supervisor can never resolve a selection into an attachment, so no offline composition could reach a session. Export a Picker facade that satisfies the supervisor's contract by construction and renders the presentation for a composition that owns the terminal writer. Rendering stays with the composition and reads geometry at render time, because the terminal's resize channel has one consumer for the whole run: the supervisor's attachment geometry collector. The facade necessarily carries the supervisor's own entry points, since that is what satisfying the contract means in Go; the type documents that a composition constructs it, assigns it once, and renders it, and never drives those entry points itself.
- commit
- eef2c8e6187c262ac04e1363c1dd029f508b41d5
- author
- brice · · 2026-09-19 12:18 UTC
- parent
- 952412b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +97 -0
A internal/usecase/client/picker_composition.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Picker is the composition seam over the client-owned picker. | |
| 11 | + | // | |
| 12 | + | // A composition constructs one picker and assigns it to | |
| 13 | + | // SupervisorConfig.Picker. The supervisor then folds every broker publication | |
| 14 | + | // into it and hands it every terminal read from the single input lifetime it | |
| 15 | + | // owns, so the picker never starts a reader of its own. The supervisor also | |
| 16 | + | // owns raw mode, the terminal writer, and the attachment geometry collector, so | |
| 17 | + | // rendering stays with the composition: it calls Render and RenderNotice with | |
| 18 | + | // the geometry it reads at render time. | |
| 19 | + | // | |
| 20 | + | // The picker deliberately does not consume the terminal's resize channel. That | |
| 21 | + | // channel has one consumer for the whole run, the supervisor's attachment | |
| 22 | + | // geometry collector, so a renderer that also selected on it would compete for | |
| 23 | + | // resizes with the attachment that owns the geometry claimant. | |
| 24 | + | // | |
| 25 | + | // Assigning this value to SupervisorConfig.Picker is what hands the supervisor | |
| 26 | + | // the picker contract, so the value necessarily also carries the supervisor's | |
| 27 | + | // own entry points (publications, ownership changes, committed keys). A | |
| 28 | + | // composition constructs the picker, assigns it once, and renders it; it never | |
| 29 | + | // drives those entry points itself, because doing so would bypass the single | |
| 30 | + | // input owner the supervisor maintains. | |
| 31 | + | type Picker struct { | |
| 32 | + | *pickerController | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Picker is the supervisor's picker seam: the value assigned to | |
| 36 | + | // SupervisorConfig.Picker. | |
| 37 | + | var _ pickerHost = (*Picker)(nil) | |
| 38 | + | ||
| 39 | + | // NewPicker composes one client-owned picker. A nil clock uses the system | |
| 40 | + | // clock, and a zero freshness uses the catalogue default. | |
| 41 | + | func NewPicker(clock ports.Clock, freshness time.Duration) *Picker { | |
| 42 | + | return &Picker{pickerController: newPickerController(clock, freshness)} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Render returns the current picker frame for size, or nil when no catalogue | |
| 46 | + | // has been applied yet. | |
| 47 | + | func (p *Picker) Render(size domain.Size) []byte { | |
| 48 | + | if p == nil || p.pickerController == nil { | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | return p.pickerController.Render(size) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // RenderNotice returns the newest bounded notice frame for size, or nil when no | |
| 55 | + | // notice is live. A composition renders it over or beside the picker frame. | |
| 56 | + | func (p *Picker) RenderNotice(size domain.Size) []byte { | |
| 57 | + | if p == nil || p.pickerController == nil { | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | return p.pickerController.RenderNotice(size) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Picker is the composition seam over the client-owned picker. | |||
| 11 | + | // | |||
| 12 | + | // A composition constructs one picker and assigns it to | |||
| 13 | + | // SupervisorConfig.Picker. The supervisor then folds every broker publication | |||
| 14 | + | // into it and hands it every terminal read from the single input lifetime it | |||
| 15 | + | // owns, so the picker never starts a reader of its own. The supervisor also | |||
| 16 | + | // owns raw mode, the terminal writer, and the attachment geometry collector, so | |||
| 17 | + | // rendering stays with the composition: it calls Render and RenderNotice with | |||
| 18 | + | // the geometry it reads at render time. | |||
| 19 | + | // | |||
| 20 | + | // The picker deliberately does not consume the terminal's resize channel. That | |||
| 21 | + | // channel has one consumer for the whole run, the supervisor's attachment | |||
| 22 | + | // geometry collector, so a renderer that also selected on it would compete for | |||
| 23 | + | // resizes with the attachment that owns the geometry claimant. | |||
| 24 | + | // | |||
| 25 | + | // Assigning this value to SupervisorConfig.Picker is what hands the supervisor | |||
| 26 | + | // the picker contract, so the value necessarily also carries the supervisor's | |||
| 27 | + | // own entry points (publications, ownership changes, committed keys). A | |||
| 28 | + | // composition constructs the picker, assigns it once, and renders it; it never | |||
| 29 | + | // drives those entry points itself, because doing so would bypass the single | |||
| 30 | + | // input owner the supervisor maintains. | |||
| 31 | + | type Picker struct { | |||
| 32 | + | *pickerController | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Picker is the supervisor's picker seam: the value assigned to | |||
| 36 | + | // SupervisorConfig.Picker. | |||
| 37 | + | var _ pickerHost = (*Picker)(nil) | |||
| 38 | + | ||||
| 39 | + | // NewPicker composes one client-owned picker. A nil clock uses the system | |||
| 40 | + | // clock, and a zero freshness uses the catalogue default. | |||
| 41 | + | func NewPicker(clock ports.Clock, freshness time.Duration) *Picker { | |||
| 42 | + | return &Picker{pickerController: newPickerController(clock, freshness)} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Render returns the current picker frame for size, or nil when no catalogue | |||
| 46 | + | // has been applied yet. | |||
| 47 | + | func (p *Picker) Render(size domain.Size) []byte { | |||
| 48 | + | if p == nil || p.pickerController == nil { | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | return p.pickerController.Render(size) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // RenderNotice returns the newest bounded notice frame for size, or nil when no | |||
| 55 | + | // notice is live. A composition renders it over or beside the picker frame. | |||
| 56 | + | func (p *Picker) RenderNotice(size domain.Size) []byte { | |||
| 57 | + | if p == nil || p.pickerController == nil { | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | return p.pickerController.RenderNotice(size) | |||
| 61 | + | } | |||
A internal/usecase/client/picker_composition_external_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package client_test | |
| 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/usecase/client" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TestCompositionOutsideThePackageCanBuildThePicker proves the composition seam | |
| 13 | + | // works from outside the package. Without it a composition could build the | |
| 14 | + | // supervisor and subscribe to broker publications, but never hand it a picker, | |
| 15 | + | // so it could never resolve a selection into an attachment. | |
| 16 | + | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | |
| 17 | + | geometry := domain.Size{Cols: 80, Rows: 24} | |
| 18 | + | picker := client.NewPicker(nil, 0) | |
| 19 | + | require.NotNil(t, picker) | |
| 20 | + | ||
| 21 | + | // The exact assignment an app composition performs. | |
| 22 | + | cfg := client.SupervisorConfig{Picker: picker} | |
| 23 | + | require.NotNil(t, cfg.Picker) | |
| 24 | + | ||
| 25 | + | // Rendering belongs to the composition, so both entry points are callable | |
| 26 | + | // with the geometry the composition reads itself. Nothing has been applied | |
| 27 | + | // yet, so both are empty rather than a frame. | |
| 28 | + | require.Empty(t, picker.Render(geometry)) | |
| 29 | + | require.Empty(t, picker.RenderNotice(geometry)) | |
| 30 | + | ||
| 31 | + | // A composition that never built a picker renders nothing instead of | |
| 32 | + | // panicking. | |
| 33 | + | var absent *client.Picker | |
| 34 | + | require.Empty(t, absent.Render(geometry)) | |
| 35 | + | require.Empty(t, absent.RenderNotice(geometry)) | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package client_test | |||
| 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/usecase/client" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestCompositionOutsideThePackageCanBuildThePicker proves the composition seam | |||
| 13 | + | // works from outside the package. Without it a composition could build the | |||
| 14 | + | // supervisor and subscribe to broker publications, but never hand it a picker, | |||
| 15 | + | // so it could never resolve a selection into an attachment. | |||
| 16 | + | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | |||
| 17 | + | geometry := domain.Size{Cols: 80, Rows: 24} | |||
| 18 | + | picker := client.NewPicker(nil, 0) | |||
| 19 | + | require.NotNil(t, picker) | |||
| 20 | + | ||||
| 21 | + | // The exact assignment an app composition performs. | |||
| 22 | + | cfg := client.SupervisorConfig{Picker: picker} | |||
| 23 | + | require.NotNil(t, cfg.Picker) | |||
| 24 | + | ||||
| 25 | + | // Rendering belongs to the composition, so both entry points are callable | |||
| 26 | + | // with the geometry the composition reads itself. Nothing has been applied | |||
| 27 | + | // yet, so both are empty rather than a frame. | |||
| 28 | + | require.Empty(t, picker.Render(geometry)) | |||
| 29 | + | require.Empty(t, picker.RenderNotice(geometry)) | |||
| 30 | + | ||||
| 31 | + | // A composition that never built a picker renders nothing instead of | |||
| 32 | + | // panicking. | |||
| 33 | + | var absent *client.Picker | |||
| 34 | + | require.Empty(t, absent.Render(geometry)) | |||
| 35 | + | require.Empty(t, absent.RenderNotice(geometry)) | |||
| 36 | + | } | |||