feat(client): render connecting and retry transitions
- commit
- 45a9fcdb9fae9b88bed727c361c3274dadb6cbfe
- author
- brice · · 2026-09-22 21:08 UTC
- parent
- 467a7dd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +101 -12
M internal/app/broker_client.go +63 -9
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "sync" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 11 | 12 | "github.com/bnema/vev/internal/adapters/clock" | |
| @@ -88,6 +89,9 @@ type brokerClientPresentation struct { | |||
| 88 | 89 | // channel. | |
| 89 | 90 | ui *client.UI | |
| 90 | 91 | onState func(client.State) | |
| 92 | + | clock ports.Clock | |
| 93 | + | spinner ports.Timer | |
| 94 | + | frame int | |
| 91 | 95 | ||
| 92 | 96 | mu sync.Mutex | |
| 93 | 97 | } | |
| @@ -116,28 +120,40 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||
| 116 | 120 | if p.onState != nil { | |
| 117 | 121 | p.onState(state) | |
| 118 | 122 | } | |
| 119 | - | if !client.PickerPresentation(state) { | |
| 123 | + | if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) { | |
| 120 | 124 | return | |
| 121 | 125 | } | |
| 126 | + | p.mu.Lock() | |
| 127 | + | defer p.mu.Unlock() | |
| 122 | 128 | geometry, err := p.terminal.Geometry() | |
| 123 | 129 | if err != nil { | |
| 124 | 130 | return | |
| 125 | 131 | } | |
| 126 | - | frame := p.picker.Render(geometry.Size) | |
| 127 | - | notice := p.picker.RenderNotice(geometry.Size) | |
| 132 | + | var frame, notice []byte | |
| 133 | + | if client.PickerPresentation(state) { | |
| 134 | + | frame = p.picker.Render(geometry.Size) | |
| 135 | + | notice = p.picker.RenderNotice(geometry.Size) | |
| 136 | + | } else { | |
| 137 | + | frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…") | |
| 138 | + | } | |
| 139 | + | if state.Connectivity == client.ConnectivityRetryWait { | |
| 140 | + | notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…") | |
| 141 | + | } | |
| 128 | 142 | if len(frame) == 0 && len(notice) == 0 { | |
| 129 | 143 | return | |
| 130 | 144 | } | |
| 131 | - | p.mu.Lock() | |
| 132 | - | defer p.mu.Unlock() | |
| 133 | 145 | writer := p.terminal.Out() | |
| 134 | 146 | if writer == nil { | |
| 135 | 147 | return | |
| 136 | 148 | } | |
| 137 | 149 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | |
| 150 | + | context := p.pickerContext() | |
| 151 | + | if state.Presentation == client.PresentConnecting { | |
| 152 | + | context.Status = ports.UIStatusConnecting | |
| 153 | + | } | |
| 138 | 154 | if transaction != nil { | |
| 139 | - | transaction.BeginOutput(p.pickerContext()) | |
| 140 | - | if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 155 | + | transaction.BeginOutput(context) | |
| 156 | + | if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 141 | 157 | transaction.EndOutput(false) | |
| 142 | 158 | return | |
| 143 | 159 | } | |
| @@ -161,6 +177,33 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||
| 161 | 177 | } | |
| 162 | 178 | } | |
| 163 | 179 | ||
| 180 | + | func (p *brokerClientPresentation) Spinner() <-chan time.Time { | |
| 181 | + | p.mu.Lock() | |
| 182 | + | defer p.mu.Unlock() | |
| 183 | + | if p.spinner == nil { | |
| 184 | + | p.spinner = p.clock.NewTimer(120 * time.Millisecond) | |
| 185 | + | } | |
| 186 | + | return p.spinner.C() | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (p *brokerClientPresentation) AdvanceSpinner(state client.State) { | |
| 190 | + | p.mu.Lock() | |
| 191 | + | p.frame++ | |
| 192 | + | if p.spinner != nil { | |
| 193 | + | p.spinner.Reset(120 * time.Millisecond) | |
| 194 | + | } | |
| 195 | + | p.mu.Unlock() | |
| 196 | + | p.Render(state) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (p *brokerClientPresentation) StopSpinner() { | |
| 200 | + | p.mu.Lock() | |
| 201 | + | if p.spinner != nil { | |
| 202 | + | p.spinner.Stop() | |
| 203 | + | } | |
| 204 | + | p.mu.Unlock() | |
| 205 | + | } | |
| 206 | + | ||
| 164 | 207 | // pickerContext is the Picker presentation of this run: the stable UI handle and | |
| 165 | 208 | // the status alone. It is the shape every unattached publication uses, so a | |
| 166 | 209 | // capture of the painted picker never shows an old attachment's session | |
| @@ -225,7 +268,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 225 | 268 | reader = clipboard.New() | |
| 226 | 269 | } | |
| 227 | 270 | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | |
| 228 | - | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | |
| 271 | + | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} | |
| 229 | 272 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 230 | 273 | Connector: cfg.Connector, | |
| 231 | 274 | Terminal: cfg.Terminal, | |
| @@ -246,7 +289,18 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 246 | 289 | } | |
| 247 | 290 | presentation.Failure(err) | |
| 248 | 291 | }, | |
| 249 | - | NotifyLifecycle: cfg.OnLifecycle, | |
| 292 | + | NotifyLifecycle: func(notice client.LifecycleNotice) { | |
| 293 | + | if cfg.OnLifecycle != nil { | |
| 294 | + | cfg.OnLifecycle(notice) | |
| 295 | + | } | |
| 296 | + | if notice.Kind == client.LifecycleNoticeBrokerLost { | |
| 297 | + | picker.OfferNotice("broker-lost", "Connection lost; retrying…") | |
| 298 | + | } | |
| 299 | + | if notice.Kind == client.LifecycleNoticeBrokerReconnected { | |
| 300 | + | picker.OfferNotice("broker-reconnected", "Connection restored") | |
| 301 | + | } | |
| 302 | + | }, | |
| 303 | + | Spinner: presentation, | |
| 250 | 304 | }) | |
| 251 | 305 | if err != nil { | |
| 252 | 306 | return err | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/vev/internal/adapters/clipboard" | 11 | "github.com/bnema/vev/internal/adapters/clipboard" | ||
| 11 | "github.com/bnema/vev/internal/adapters/clock" | 12 | "github.com/bnema/vev/internal/adapters/clock" | ||
| @@ -88,6 +89,9 @@ type brokerClientPresentation struct { | |||||
| 88 | // channel. | 89 | // channel. | ||
| 89 | ui *client.UI | 90 | ui *client.UI | ||
| 90 | onState func(client.State) | 91 | onState func(client.State) | ||
| 92 | + | clock ports.Clock | |||
| 93 | + | spinner ports.Timer | |||
| 94 | + | frame int | |||
| 91 | 95 | ||||
| 92 | mu sync.Mutex | 96 | mu sync.Mutex | ||
| 93 | } | 97 | } | ||
| @@ -116,28 +120,40 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||||
| 116 | if p.onState != nil { | 120 | if p.onState != nil { | ||
| 117 | p.onState(state) | 121 | p.onState(state) | ||
| 118 | } | 122 | } | ||
| 119 | - | if !client.PickerPresentation(state) { | 123 | + | if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) { |
| 120 | return | 124 | return | ||
| 121 | } | 125 | } | ||
| 126 | + | p.mu.Lock() | |||
| 127 | + | defer p.mu.Unlock() | |||
| 122 | geometry, err := p.terminal.Geometry() | 128 | geometry, err := p.terminal.Geometry() | ||
| 123 | if err != nil { | 129 | if err != nil { | ||
| 124 | return | 130 | return | ||
| 125 | } | 131 | } | ||
| 126 | - | frame := p.picker.Render(geometry.Size) | 132 | + | var frame, notice []byte |
| 127 | - | notice := p.picker.RenderNotice(geometry.Size) | 133 | + | if client.PickerPresentation(state) { |
| 134 | + | frame = p.picker.Render(geometry.Size) | |||
| 135 | + | notice = p.picker.RenderNotice(geometry.Size) | |||
| 136 | + | } else { | |||
| 137 | + | frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…") | |||
| 138 | + | } | |||
| 139 | + | if state.Connectivity == client.ConnectivityRetryWait { | |||
| 140 | + | notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…") | |||
| 141 | + | } | |||
| 128 | if len(frame) == 0 && len(notice) == 0 { | 142 | if len(frame) == 0 && len(notice) == 0 { | ||
| 129 | return | 143 | return | ||
| 130 | } | 144 | } | ||
| 131 | - | p.mu.Lock() | |||
| 132 | - | defer p.mu.Unlock() | |||
| 133 | writer := p.terminal.Out() | 145 | writer := p.terminal.Out() | ||
| 134 | if writer == nil { | 146 | if writer == nil { | ||
| 135 | return | 147 | return | ||
| 136 | } | 148 | } | ||
| 137 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | 149 | transaction, _ := p.terminal.(ports.UIOutputTransaction) | ||
| 150 | + | context := p.pickerContext() | |||
| 151 | + | if state.Presentation == client.PresentConnecting { | |||
| 152 | + | context.Status = ports.UIStatusConnecting | |||
| 153 | + | } | |||
| 138 | if transaction != nil { | 154 | if transaction != nil { | ||
| 139 | - | transaction.BeginOutput(p.pickerContext()) | 155 | + | transaction.BeginOutput(context) |
| 140 | - | if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | 156 | + | if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { |
| 141 | transaction.EndOutput(false) | 157 | transaction.EndOutput(false) | ||
| 142 | return | 158 | return | ||
| 143 | } | 159 | } | ||
| @@ -161,6 +177,33 @@ func (p *brokerClientPresentation) Render(state client.State) { | |||||
| 161 | } | 177 | } | ||
| 162 | } | 178 | } | ||
| 163 | 179 | ||||
| 180 | + | func (p *brokerClientPresentation) Spinner() <-chan time.Time { | |||
| 181 | + | p.mu.Lock() | |||
| 182 | + | defer p.mu.Unlock() | |||
| 183 | + | if p.spinner == nil { | |||
| 184 | + | p.spinner = p.clock.NewTimer(120 * time.Millisecond) | |||
| 185 | + | } | |||
| 186 | + | return p.spinner.C() | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (p *brokerClientPresentation) AdvanceSpinner(state client.State) { | |||
| 190 | + | p.mu.Lock() | |||
| 191 | + | p.frame++ | |||
| 192 | + | if p.spinner != nil { | |||
| 193 | + | p.spinner.Reset(120 * time.Millisecond) | |||
| 194 | + | } | |||
| 195 | + | p.mu.Unlock() | |||
| 196 | + | p.Render(state) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (p *brokerClientPresentation) StopSpinner() { | |||
| 200 | + | p.mu.Lock() | |||
| 201 | + | if p.spinner != nil { | |||
| 202 | + | p.spinner.Stop() | |||
| 203 | + | } | |||
| 204 | + | p.mu.Unlock() | |||
| 205 | + | } | |||
| 206 | + | ||||
| 164 | // pickerContext is the Picker presentation of this run: the stable UI handle and | 207 | // pickerContext is the Picker presentation of this run: the stable UI handle and | ||
| 165 | // the status alone. It is the shape every unattached publication uses, so a | 208 | // the status alone. It is the shape every unattached publication uses, so a | ||
| 166 | // capture of the painted picker never shows an old attachment's session | 209 | // capture of the painted picker never shows an old attachment's session | ||
| @@ -225,7 +268,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 225 | reader = clipboard.New() | 268 | reader = clipboard.New() | ||
| 226 | } | 269 | } | ||
| 227 | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | 270 | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | ||
| 228 | - | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | 271 | + | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk} |
| 229 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | 272 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | ||
| 230 | Connector: cfg.Connector, | 273 | Connector: cfg.Connector, | ||
| 231 | Terminal: cfg.Terminal, | 274 | Terminal: cfg.Terminal, | ||
| @@ -246,7 +289,18 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 246 | } | 289 | } | ||
| 247 | presentation.Failure(err) | 290 | presentation.Failure(err) | ||
| 248 | }, | 291 | }, | ||
| 249 | - | NotifyLifecycle: cfg.OnLifecycle, | 292 | + | NotifyLifecycle: func(notice client.LifecycleNotice) { |
| 293 | + | if cfg.OnLifecycle != nil { | |||
| 294 | + | cfg.OnLifecycle(notice) | |||
| 295 | + | } | |||
| 296 | + | if notice.Kind == client.LifecycleNoticeBrokerLost { | |||
| 297 | + | picker.OfferNotice("broker-lost", "Connection lost; retrying…") | |||
| 298 | + | } | |||
| 299 | + | if notice.Kind == client.LifecycleNoticeBrokerReconnected { | |||
| 300 | + | picker.OfferNotice("broker-reconnected", "Connection restored") | |||
| 301 | + | } | |||
| 302 | + | }, | |||
| 303 | + | Spinner: presentation, | |||
| 250 | }) | 304 | }) | ||
| 251 | if err != nil { | 305 | if err != nil { | ||
| 252 | return err | 306 | return err | ||
M internal/usecase/client/picker_composition.go +4 -2
| @@ -130,13 +130,15 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||
| 130 | 130 | p.controller.SetOwnsInput(owns) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | - | // offerNotice shows one bounded client-local notice on the picker. | |
| 134 | - | func (p *Picker) offerNotice(id, message string) { | |
| 133 | + | // OfferNotice shows one bounded client-local notice on the picker. | |
| 134 | + | func (p *Picker) OfferNotice(id, message string) { | |
| 135 | 135 | if p != nil && p.controller != nil { | |
| 136 | 136 | p.controller.offerNotice(id, message) | |
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | + | func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) } | |
| 141 | + | ||
| 140 | 142 | // invalidatePresentation forces the next frame to redraw the whole box. | |
| 141 | 143 | func (p *Picker) invalidatePresentation() { | |
| 142 | 144 | if p != nil && p.controller != nil { | |
| @@ -130,13 +130,15 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||||
| 130 | p.controller.SetOwnsInput(owns) | 130 | p.controller.SetOwnsInput(owns) | ||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | - | // offerNotice shows one bounded client-local notice on the picker. | 133 | + | // OfferNotice shows one bounded client-local notice on the picker. |
| 134 | - | func (p *Picker) offerNotice(id, message string) { | 134 | + | func (p *Picker) OfferNotice(id, message string) { |
| 135 | if p != nil && p.controller != nil { | 135 | if p != nil && p.controller != nil { | ||
| 136 | p.controller.offerNotice(id, message) | 136 | p.controller.offerNotice(id, message) | ||
| 137 | } | 137 | } | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | + | func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) } | |||
| 141 | + | ||||
| 140 | // invalidatePresentation forces the next frame to redraw the whole box. | 142 | // invalidatePresentation forces the next frame to redraw the whole box. | ||
| 141 | func (p *Picker) invalidatePresentation() { | 143 | func (p *Picker) invalidatePresentation() { | ||
| 142 | if p != nil && p.controller != nil { | 144 | if p != nil && p.controller != nil { | ||
M internal/usecase/client/supervisor.go +24 -1
| @@ -281,6 +281,14 @@ type LifecycleNotice struct { | |||
| 281 | 281 | // connector, terminal, and clock are required; render, notify, and jitter are | |
| 282 | 282 | // injectable seams with safe defaults. Render, notify, and jitter must not | |
| 283 | 283 | // block: the supervisor drives them from its single run goroutine. | |
| 284 | + | // SpinnerPresentation advances a transition frame on the serialized control | |
| 285 | + | // path, never from a competing terminal writer goroutine. | |
| 286 | + | type SpinnerPresentation interface { | |
| 287 | + | Spinner() <-chan time.Time | |
| 288 | + | AdvanceSpinner(State) | |
| 289 | + | StopSpinner() | |
| 290 | + | } | |
| 291 | + | ||
| 284 | 292 | type SupervisorConfig struct { | |
| 285 | 293 | // Connector establishes the broker connection. Required. | |
| 286 | 294 | Connector ports.BrokerConnector | |
| @@ -303,7 +311,8 @@ type SupervisorConfig struct { | |||
| 303 | 311 | // overwrite session output. An attached foreground still owns the terminal | |
| 304 | 312 | // and a terminating process is leaving it, so neither is ever painted over | |
| 305 | 313 | // by a resize invalidation. Optional; the default renders nothing. | |
| 306 | - | Render func(State) | |
| 314 | + | Render func(State) | |
| 315 | + | Spinner SpinnerPresentation | |
| 307 | 316 | // Notify surfaces a connectivity failure without leaving the picker. | |
| 308 | 317 | // Optional; the default notifies nothing. | |
| 309 | 318 | Notify func(State, error) | |
| @@ -785,6 +794,8 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||
| 785 | 794 | return supervisorAttempt{}, true, terminalReadCause(err) | |
| 786 | 795 | case <-s.presentationInvalidation(): | |
| 787 | 796 | s.renderResizeInvalidation() | |
| 797 | + | case <-s.spinnerTick(): | |
| 798 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |
| 788 | 799 | } | |
| 789 | 800 | } | |
| 790 | 801 | } | |
| @@ -985,6 +996,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||
| 985 | 996 | return true, terminalReadCause(err) | |
| 986 | 997 | case <-s.presentationInvalidation(): | |
| 987 | 998 | s.renderResizeInvalidation() | |
| 999 | + | case <-s.spinnerTick(): | |
| 1000 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |
| 988 | 1001 | } | |
| 989 | 1002 | } | |
| 990 | 1003 | } | |
| @@ -1120,11 +1133,21 @@ func (s *Supervisor) transition(event supervisorEvent) { | |||
| 1120 | 1133 | if state.Presentation != previous { | |
| 1121 | 1134 | s.publishPresentation(state.Presentation) | |
| 1122 | 1135 | } | |
| 1136 | + | if s.cfg.Spinner != nil && state.Presentation != PresentConnecting && state.Connectivity != ConnectivityRetryWait && state.Connectivity != ConnectivityConnectingBroker { | |
| 1137 | + | s.cfg.Spinner.StopSpinner() | |
| 1138 | + | } | |
| 1123 | 1139 | if s.cfg.Render != nil { | |
| 1124 | 1140 | s.cfg.Render(state) | |
| 1125 | 1141 | } | |
| 1126 | 1142 | } | |
| 1127 | 1143 | ||
| 1144 | + | func (s *Supervisor) spinnerTick() <-chan time.Time { | |
| 1145 | + | if s.cfg.Spinner == nil { | |
| 1146 | + | return nil | |
| 1147 | + | } | |
| 1148 | + | return s.cfg.Spinner.Spinner() | |
| 1149 | + | } | |
| 1150 | + | ||
| 1128 | 1151 | // publishPresentation publishes one supervisor-owned presentation through the | |
| 1129 | 1152 | // optional UI owner. Attached and terminating are refused: Attached belongs to | |
| 1130 | 1153 | // the admitted attachment foreground, and terminating ends the service. | |
| @@ -281,6 +281,14 @@ type LifecycleNotice struct { | |||||
| 281 | // connector, terminal, and clock are required; render, notify, and jitter are | 281 | // connector, terminal, and clock are required; render, notify, and jitter are | ||
| 282 | // injectable seams with safe defaults. Render, notify, and jitter must not | 282 | // injectable seams with safe defaults. Render, notify, and jitter must not | ||
| 283 | // block: the supervisor drives them from its single run goroutine. | 283 | // block: the supervisor drives them from its single run goroutine. | ||
| 284 | + | // SpinnerPresentation advances a transition frame on the serialized control | |||
| 285 | + | // path, never from a competing terminal writer goroutine. | |||
| 286 | + | type SpinnerPresentation interface { | |||
| 287 | + | Spinner() <-chan time.Time | |||
| 288 | + | AdvanceSpinner(State) | |||
| 289 | + | StopSpinner() | |||
| 290 | + | } | |||
| 291 | + | ||||
| 284 | type SupervisorConfig struct { | 292 | type SupervisorConfig struct { | ||
| 285 | // Connector establishes the broker connection. Required. | 293 | // Connector establishes the broker connection. Required. | ||
| 286 | Connector ports.BrokerConnector | 294 | Connector ports.BrokerConnector | ||
| @@ -303,7 +311,8 @@ type SupervisorConfig struct { | |||||
| 303 | // overwrite session output. An attached foreground still owns the terminal | 311 | // overwrite session output. An attached foreground still owns the terminal | ||
| 304 | // and a terminating process is leaving it, so neither is ever painted over | 312 | // and a terminating process is leaving it, so neither is ever painted over | ||
| 305 | // by a resize invalidation. Optional; the default renders nothing. | 313 | // by a resize invalidation. Optional; the default renders nothing. | ||
| 306 | - | Render func(State) | 314 | + | Render func(State) |
| 315 | + | Spinner SpinnerPresentation | |||
| 307 | // Notify surfaces a connectivity failure without leaving the picker. | 316 | // Notify surfaces a connectivity failure without leaving the picker. | ||
| 308 | // Optional; the default notifies nothing. | 317 | // Optional; the default notifies nothing. | ||
| 309 | Notify func(State, error) | 318 | Notify func(State, error) | ||
| @@ -785,6 +794,8 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||||
| 785 | return supervisorAttempt{}, true, terminalReadCause(err) | 794 | return supervisorAttempt{}, true, terminalReadCause(err) | ||
| 786 | case <-s.presentationInvalidation(): | 795 | case <-s.presentationInvalidation(): | ||
| 787 | s.renderResizeInvalidation() | 796 | s.renderResizeInvalidation() | ||
| 797 | + | case <-s.spinnerTick(): | |||
| 798 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |||
| 788 | } | 799 | } | ||
| 789 | } | 800 | } | ||
| 790 | } | 801 | } | ||
| @@ -985,6 +996,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||||
| 985 | return true, terminalReadCause(err) | 996 | return true, terminalReadCause(err) | ||
| 986 | case <-s.presentationInvalidation(): | 997 | case <-s.presentationInvalidation(): | ||
| 987 | s.renderResizeInvalidation() | 998 | s.renderResizeInvalidation() | ||
| 999 | + | case <-s.spinnerTick(): | |||
| 1000 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |||
| 988 | } | 1001 | } | ||
| 989 | } | 1002 | } | ||
| 990 | } | 1003 | } | ||
| @@ -1120,11 +1133,21 @@ func (s *Supervisor) transition(event supervisorEvent) { | |||||
| 1120 | if state.Presentation != previous { | 1133 | if state.Presentation != previous { | ||
| 1121 | s.publishPresentation(state.Presentation) | 1134 | s.publishPresentation(state.Presentation) | ||
| 1122 | } | 1135 | } | ||
| 1136 | + | if s.cfg.Spinner != nil && state.Presentation != PresentConnecting && state.Connectivity != ConnectivityRetryWait && state.Connectivity != ConnectivityConnectingBroker { | |||
| 1137 | + | s.cfg.Spinner.StopSpinner() | |||
| 1138 | + | } | |||
| 1123 | if s.cfg.Render != nil { | 1139 | if s.cfg.Render != nil { | ||
| 1124 | s.cfg.Render(state) | 1140 | s.cfg.Render(state) | ||
| 1125 | } | 1141 | } | ||
| 1126 | } | 1142 | } | ||
| 1127 | 1143 | ||||
| 1144 | + | func (s *Supervisor) spinnerTick() <-chan time.Time { | |||
| 1145 | + | if s.cfg.Spinner == nil { | |||
| 1146 | + | return nil | |||
| 1147 | + | } | |||
| 1148 | + | return s.cfg.Spinner.Spinner() | |||
| 1149 | + | } | |||
| 1150 | + | ||||
| 1128 | // publishPresentation publishes one supervisor-owned presentation through the | 1151 | // publishPresentation publishes one supervisor-owned presentation through the | ||
| 1129 | // optional UI owner. Attached and terminating are refused: Attached belongs to | 1152 | // optional UI owner. Attached and terminating are refused: Attached belongs to | ||
| 1130 | // the admitted attachment foreground, and terminating ends the service. | 1153 | // the admitted attachment foreground, and terminating ends the service. | ||
M internal/usecase/client/transition_ui.go +10 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "io" | |
| 6 | 7 | "time" | |
| @@ -30,6 +31,15 @@ func transitionMessage(target protocol.AttachTarget) string { | |||
| 30 | 31 | return verb + label + "…" | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 34 | + | // RenderTransitionNotice draws one connecting frame for the terminal owner. | |
| 35 | + | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | |
| 36 | + | var out bytes.Buffer | |
| 37 | + | if _, err := drawTransitionToast(&out, size, frame, message); err != nil { | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | return out.Bytes() | |
| 41 | + | } | |
| 42 | + | ||
| 33 | 43 | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | |
| 34 | 44 | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | |
| 35 | 45 | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package client | 1 | package client | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "io" | 6 | "io" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -30,6 +31,15 @@ func transitionMessage(target protocol.AttachTarget) string { | |||||
| 30 | return verb + label + "…" | 31 | return verb + label + "…" | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | // RenderTransitionNotice draws one connecting frame for the terminal owner. | |||
| 35 | + | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | |||
| 36 | + | var out bytes.Buffer | |||
| 37 | + | if _, err := drawTransitionToast(&out, size, frame, message); err != nil { | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | return out.Bytes() | |||
| 41 | + | } | |||
| 42 | + | ||||
| 33 | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | 43 | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | ||
| 34 | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | 44 | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | ||
| 35 | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | 45 | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | ||