feat(ui): queue and stack toast notifications (#296)
* feat(ui): queue and stack picker notices with a reusable ToastQueue Add ui.ToastQueue with bounded visible, pending, and history lists, and ui.ToastStackBounds for any anchor. The picker now stacks up to three notices top-right, queues the rest, blanks boxes that go away, and wakes on the next expiry. Kill notices are scoped per session so consecutive kills stack instead of replacing each other. * refactor(notices): unify daemon and picker toasts on a generic ToastQueue Make ui.ToastQueue generic and use it for daemon toasts too: a burst beyond three visible toasts now waits and is shown on expiry instead of being dropped, with one re-armed timer per attachment. Remove ToastManager and CompositeToasts. The picker wakes on notice expiry in every picker-visible wait, clips blanks to the current screen, and invalidates its box after blanking.
- commit
- cd9b41fa0ed50d11c660d681be6a7ea938765130
- author
- bnema · · 2026-09-26 15:11 UTC
- committer
- GitHub · · 2026-09-26 15:11 UTC
- parent
- 46a921f
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
18 files · +698 -509
M internal/domain/notice.go +1 -1
| @@ -129,7 +129,7 @@ type Notification struct { | |||
| 129 | 129 | ||
| 130 | 130 | // Subject is the stable identity of what a notice is about. Client toasts | |
| 131 | 131 | // use it so a newer notice for the same subject replaces the visible one; | |
| 132 | - | // the daemon's notice coalescing keeps its own rule (sameToastNotice). | |
| 132 | + | // the daemon's notice coalescing keeps its own rule (daemon toastNoticeID). | |
| 133 | 133 | func (n Notification) Subject() string { | |
| 134 | 134 | return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope | |
| 135 | 135 | } | |
| @@ -129,7 +129,7 @@ type Notification struct { | |||||
| 129 | 129 | ||||
| 130 | // Subject is the stable identity of what a notice is about. Client toasts | 130 | // Subject is the stable identity of what a notice is about. Client toasts | ||
| 131 | // use it so a newer notice for the same subject replaces the visible one; | 131 | // use it so a newer notice for the same subject replaces the visible one; | ||
| 132 | - | // the daemon's notice coalescing keeps its own rule (sameToastNotice). | 132 | + | // the daemon's notice coalescing keeps its own rule (daemon toastNoticeID). |
| 133 | func (n Notification) Subject() string { | 133 | func (n Notification) Subject() string { | ||
| 134 | return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope | 134 | return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope | ||
| 135 | } | 135 | } | ||
M internal/usecase/client/client_toast.go +12 -2
| @@ -45,10 +45,10 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 45 | 45 | return nil | |
| 46 | 46 | } | |
| 47 | 47 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | |
| 48 | - | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{ | |
| 48 | + | ui.DrawToast(frame, ui.Toast{Message: message, Anchor: domain.AnchorCenter}, ui.ToastStyles{ | |
| 49 | 49 | Text: renderer.DefaultStyle(), | |
| 50 | 50 | Box: renderer.DefaultStyle(), | |
| 51 | - | }, nil) | |
| 51 | + | }) | |
| 52 | 52 | lines := make([]string, bounds.Height) | |
| 53 | 53 | for y := range bounds.Height { | |
| 54 | 54 | var b strings.Builder | |
| @@ -83,6 +83,16 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 83 | 83 | return lines | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | // blankToastLines erases a toast box. The session under it belongs to the | |
| 87 | + | // daemon, so blank cells are the best the client can restore. | |
| 88 | + | func blankToastLines(bounds domain.Rect) []string { | |
| 89 | + | lines := make([]string, max(0, bounds.Height)) | |
| 90 | + | for i := range lines { | |
| 91 | + | lines[i] = strings.Repeat(" ", max(0, bounds.Width)) | |
| 92 | + | } | |
| 93 | + | return lines | |
| 94 | + | } | |
| 95 | + | ||
| 86 | 96 | func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error { | |
| 87 | 97 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | |
| 88 | 98 | return err | |
| @@ -45,10 +45,10 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||||
| 45 | return nil | 45 | return nil | ||
| 46 | } | 46 | } | ||
| 47 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | 47 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | ||
| 48 | - | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{ | 48 | + | ui.DrawToast(frame, ui.Toast{Message: message, Anchor: domain.AnchorCenter}, ui.ToastStyles{ |
| 49 | Text: renderer.DefaultStyle(), | 49 | Text: renderer.DefaultStyle(), | ||
| 50 | Box: renderer.DefaultStyle(), | 50 | Box: renderer.DefaultStyle(), | ||
| 51 | - | }, nil) | 51 | + | }) |
| 52 | lines := make([]string, bounds.Height) | 52 | lines := make([]string, bounds.Height) | ||
| 53 | for y := range bounds.Height { | 53 | for y := range bounds.Height { | ||
| 54 | var b strings.Builder | 54 | var b strings.Builder | ||
| @@ -83,6 +83,16 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||||
| 83 | return lines | 83 | return lines | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | + | // blankToastLines erases a toast box. The session under it belongs to the | |||
| 87 | + | // daemon, so blank cells are the best the client can restore. | |||
| 88 | + | func blankToastLines(bounds domain.Rect) []string { | |||
| 89 | + | lines := make([]string, max(0, bounds.Height)) | |||
| 90 | + | for i := range lines { | |||
| 91 | + | lines[i] = strings.Repeat(" ", max(0, bounds.Width)) | |||
| 92 | + | } | |||
| 93 | + | return lines | |||
| 94 | + | } | |||
| 95 | + | ||||
| 86 | func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error { | 96 | func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error { | ||
| 87 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | 97 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | ||
| 88 | return err | 98 | return err | ||
M internal/usecase/client/picker_composition.go +10 -3
| @@ -138,6 +138,13 @@ func (p *Picker) Notify(n domain.Notification) { | |||
| 138 | 138 | } | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | + | func (p *Picker) noticeDeadline() (time.Time, bool) { | |
| 142 | + | if p == nil || p.controller == nil { | |
| 143 | + | return time.Time{}, false | |
| 144 | + | } | |
| 145 | + | return p.controller.noticeDeadline() | |
| 146 | + | } | |
| 147 | + | ||
| 141 | 148 | // invalidatePresentation forces the next frame to redraw the whole box. | |
| 142 | 149 | func (p *Picker) invalidatePresentation() { | |
| 143 | 150 | if p != nil && p.controller != nil { | |
| @@ -173,9 +180,9 @@ func (p *Picker) Render(size domain.Size) []byte { | |||
| 173 | 180 | return p.controller.Render(size) | |
| 174 | 181 | } | |
| 175 | 182 | ||
| 176 | - | // RenderNotice returns the newest bounded notice frame for size, or nil when no | |
| 177 | - | // notice is live. A composition renders it over or beside the picker frame only | |
| 178 | - | // while the supervisor reports PresentPicker. | |
| 183 | + | // RenderNotice returns the notice stack for size, including blanks for boxes | |
| 184 | + | // that went away, or nil when nothing changes on screen. A composition paints | |
| 185 | + | // it after the picker frame only while the supervisor reports PresentPicker. | |
| 179 | 186 | func (p *Picker) RenderNotice(size domain.Size) []byte { | |
| 180 | 187 | if p == nil || p.controller == nil { | |
| 181 | 188 | return nil | |
| @@ -138,6 +138,13 @@ func (p *Picker) Notify(n domain.Notification) { | |||||
| 138 | } | 138 | } | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | + | func (p *Picker) noticeDeadline() (time.Time, bool) { | |||
| 142 | + | if p == nil || p.controller == nil { | |||
| 143 | + | return time.Time{}, false | |||
| 144 | + | } | |||
| 145 | + | return p.controller.noticeDeadline() | |||
| 146 | + | } | |||
| 147 | + | ||||
| 141 | // invalidatePresentation forces the next frame to redraw the whole box. | 148 | // invalidatePresentation forces the next frame to redraw the whole box. | ||
| 142 | func (p *Picker) invalidatePresentation() { | 149 | func (p *Picker) invalidatePresentation() { | ||
| 143 | if p != nil && p.controller != nil { | 150 | if p != nil && p.controller != nil { | ||
| @@ -173,9 +180,9 @@ func (p *Picker) Render(size domain.Size) []byte { | |||||
| 173 | return p.controller.Render(size) | 180 | return p.controller.Render(size) | ||
| 174 | } | 181 | } | ||
| 175 | 182 | ||||
| 176 | - | // RenderNotice returns the newest bounded notice frame for size, or nil when no | 183 | + | // RenderNotice returns the notice stack for size, including blanks for boxes |
| 177 | - | // notice is live. A composition renders it over or beside the picker frame only | 184 | + | // that went away, or nil when nothing changes on screen. A composition paints |
| 178 | - | // while the supervisor reports PresentPicker. | 185 | + | // it after the picker frame only while the supervisor reports PresentPicker. |
| 179 | func (p *Picker) RenderNotice(size domain.Size) []byte { | 186 | func (p *Picker) RenderNotice(size domain.Size) []byte { | ||
| 180 | if p == nil || p.controller == nil { | 187 | if p == nil || p.controller == nil { | ||
| 181 | return nil | 188 | return nil | ||
M internal/usecase/client/picker_controller.go +83 -28
| @@ -3,6 +3,7 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "errors" | |
| 6 | + | "slices" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -83,6 +84,8 @@ const ( | |||
| 83 | 84 | pickerGeneration = uint64(1) | |
| 84 | 85 | // pickerNoticeLifetime bounds how long one catalogue notice is displayed. | |
| 85 | 86 | pickerNoticeLifetime = 4 * time.Second | |
| 87 | + | // pickerNoticeVisible is how many notices stack at once; later ones wait. | |
| 88 | + | pickerNoticeVisible = 3 | |
| 86 | 89 | ) | |
| 87 | 90 | ||
| 88 | 91 | // pickerController owns one client-owned picker: catalogue, model, renderer, | |
| @@ -92,12 +95,15 @@ type pickerController struct { | |||
| 92 | 95 | clock ports.Clock | |
| 93 | 96 | catalogue *pickerCatalogue | |
| 94 | 97 | ||
| 95 | - | mu sync.Mutex | |
| 96 | - | loop *pickerLoop | |
| 97 | - | consumer pickerConsumer | |
| 98 | - | renderer *pickerRenderer | |
| 99 | - | notices ui.ToastManager | |
| 100 | - | interaction uint64 | |
| 98 | + | mu sync.Mutex | |
| 99 | + | loop *pickerLoop | |
| 100 | + | consumer pickerConsumer | |
| 101 | + | renderer *pickerRenderer | |
| 102 | + | notices *ui.ToastQueue[ui.Toast] | |
| 103 | + | // noticeBounds are the toast boxes drawn last. The client cannot restore | |
| 104 | + | // the session under them, so boxes that go away are blanked. | |
| 105 | + | noticeBounds []domain.Rect | |
| 106 | + | interaction uint64 | |
| 101 | 107 | // sort is this client's picker ordering. It survives every picker | |
| 102 | 108 | // reopen for the lifetime of the client process. | |
| 103 | 109 | sort pickerusecase.SortMode | |
| @@ -130,6 +136,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration, trueColor b | |||
| 130 | 136 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 131 | 137 | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | |
| 132 | 138 | sort: defaultPickerSort(), | |
| 139 | + | notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}), | |
| 133 | 140 | ownsInput: true, | |
| 134 | 141 | opsReady: make(chan struct{}, 1), | |
| 135 | 142 | } | |
| @@ -360,6 +367,8 @@ func (p *pickerController) invalidatePresentation() { | |||
| 360 | 367 | } | |
| 361 | 368 | p.mu.Lock() | |
| 362 | 369 | p.renderer.invalidate() | |
| 370 | + | // Another owner repainted the screen: the old toast cells are gone. | |
| 371 | + | p.noticeBounds = nil | |
| 363 | 372 | p.mu.Unlock() | |
| 364 | 373 | } | |
| 365 | 374 | ||
| @@ -385,7 +394,14 @@ func (p *pickerController) Render(size domain.Size) []byte { | |||
| 385 | 394 | if p.loop == nil { | |
| 386 | 395 | return nil | |
| 387 | 396 | } | |
| 388 | - | return p.renderer.render(p.loop, size, p.preview) | |
| 397 | + | // Blank gone notices before the box is drawn, so a blank that overlaps | |
| 398 | + | // the box never erases cells the picker renderer believes are on screen. | |
| 399 | + | // A blank may also cover part of the box, so the box is then redrawn whole. | |
| 400 | + | _, blanks := p.noticeStackLocked(size) | |
| 401 | + | if len(blanks) != 0 { | |
| 402 | + | p.renderer.invalidate() | |
| 403 | + | } | |
| 404 | + | return append(blanks, p.renderer.render(p.loop, size, p.preview)...) | |
| 389 | 405 | } | |
| 390 | 406 | ||
| 391 | 407 | // SetPreview displays one preview frame in the given state. A frame is shown | |
| @@ -468,37 +484,75 @@ func (p *pickerController) ClearPreview() { | |||
| 468 | 484 | p.mu.Unlock() | |
| 469 | 485 | } | |
| 470 | 486 | ||
| 471 | - | // RenderNotice composes the newest bounded catalogue notice as a client-local | |
| 472 | - | // toast. It reuses the client's existing toast rendering and returns nil when | |
| 473 | - | // nothing is displayed. | |
| 487 | + | // RenderNotice composes the live notice stack as client-local toasts: the | |
| 488 | + | // newest at the top-right corner, older ones below it, and any extra notices | |
| 489 | + | // waiting for a free slot. Boxes drawn by the previous call that are gone now | |
| 490 | + | // are blanked in the same payload. It returns nil when nothing changes on | |
| 491 | + | // screen. | |
| 474 | 492 | func (p *pickerController) RenderNotice(size domain.Size) []byte { | |
| 475 | 493 | if p == nil { | |
| 476 | 494 | return nil | |
| 477 | 495 | } | |
| 478 | 496 | p.mu.Lock() | |
| 479 | - | active := p.notices.Active(p.clock.Now()) | |
| 480 | - | p.mu.Unlock() | |
| 481 | - | if len(active) == 0 { | |
| 482 | - | return nil | |
| 497 | + | defer p.mu.Unlock() | |
| 498 | + | toasts, blanks := p.noticeStackLocked(size) | |
| 499 | + | if len(blanks) != 0 { | |
| 500 | + | // A blank may cover picker box cells drawn by the previous Render. | |
| 501 | + | p.renderer.invalidate() | |
| 483 | 502 | } | |
| 484 | - | // ToastManager.Active returns toasts in map-iteration order, so "the last | |
| 485 | - | // one" is arbitrary and can change between frames. Select the newest by | |
| 486 | - | // ShownAt, tie-broken deterministically by ID. | |
| 487 | - | newest := active[0] | |
| 488 | - | for _, toast := range active[1:] { | |
| 489 | - | if toast.ShownAt.After(newest.ShownAt) || | |
| 490 | - | (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) { | |
| 491 | - | newest = toast | |
| 492 | - | } | |
| 503 | + | buffer := bytes.NewBuffer(blanks) | |
| 504 | + | for i, rect := range p.noticeBounds { | |
| 505 | + | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity))) | |
| 493 | 506 | } | |
| 494 | - | if newest.Message == "" { | |
| 507 | + | if buffer.Len() == 0 { | |
| 495 | 508 | return nil | |
| 496 | 509 | } | |
| 510 | + | return buffer.Bytes() | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // noticeStackLocked lays out the live notices for size and returns them with | |
| 514 | + | // the bytes that blank boxes drawn before but no longer part of the stack. | |
| 515 | + | // It records the new layout in noticeBounds. | |
| 516 | + | func (p *pickerController) noticeStackLocked(size domain.Size) ([]ui.Toast, []byte) { | |
| 517 | + | active := p.notices.Visible(p.clock.Now()) | |
| 518 | + | toasts := make([]ui.Toast, len(active)) | |
| 519 | + | for i, t := range active { | |
| 520 | + | toasts[i] = t.Value | |
| 521 | + | } | |
| 522 | + | bounds := ui.ToastStackBounds(size, toasts) | |
| 497 | 523 | var buffer bytes.Buffer | |
| 498 | - | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil { | |
| 499 | - | return nil | |
| 524 | + | for _, old := range p.noticeBounds { | |
| 525 | + | if slices.Contains(bounds, old) { | |
| 526 | + | continue | |
| 527 | + | } | |
| 528 | + | // After a shrink the old box may overhang the screen; writing past the | |
| 529 | + | // edge would wrap onto unrelated cells. | |
| 530 | + | if gone := clipRect(old, size); gone.Width > 0 && gone.Height > 0 { | |
| 531 | + | _ = writeClientToast(&buffer, gone, blankToastLines(gone)) | |
| 532 | + | } | |
| 500 | 533 | } | |
| 501 | - | return buffer.Bytes() | |
| 534 | + | p.noticeBounds = bounds | |
| 535 | + | return toasts[:len(bounds)], buffer.Bytes() | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | // clipRect is the part of r inside a size-sized screen. | |
| 539 | + | func clipRect(r domain.Rect, size domain.Size) domain.Rect { | |
| 540 | + | x0, y0 := max(r.X, 0), max(r.Y, 0) | |
| 541 | + | x1, y1 := min(r.X+r.Width, size.Cols), min(r.Y+r.Height, size.Rows) | |
| 542 | + | if x1 <= x0 || y1 <= y0 { | |
| 543 | + | return domain.Rect{} | |
| 544 | + | } | |
| 545 | + | return domain.Rect{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0} | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // noticeDeadline is when the notice stack next changes on its own. | |
| 549 | + | func (p *pickerController) noticeDeadline() (time.Time, bool) { | |
| 550 | + | if p == nil { | |
| 551 | + | return time.Time{}, false | |
| 552 | + | } | |
| 553 | + | p.mu.Lock() | |
| 554 | + | defer p.mu.Unlock() | |
| 555 | + | return p.notices.NextDeadline() | |
| 502 | 556 | } | |
| 503 | 557 | ||
| 504 | 558 | // CursorKey reports the opaque key of the row under the cursor, or false when | |
| @@ -620,6 +674,7 @@ func (p *pickerController) SetCurrent(current pickerCurrent) { | |||
| 620 | 674 | p.loop = nil | |
| 621 | 675 | p.rebuildLocked() | |
| 622 | 676 | p.renderer.invalidate() | |
| 677 | + | p.noticeBounds = nil | |
| 623 | 678 | } | |
| 624 | 679 | ||
| 625 | 680 | // rememberSortLocked keeps the sort the user chose in the closing | |
| @@ -751,7 +806,7 @@ func (p *pickerController) notifyLocked(n domain.Notification) { | |||
| 751 | 806 | if n.Message == "" { | |
| 752 | 807 | return | |
| 753 | 808 | } | |
| 754 | - | p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | |
| 809 | + | p.notices.Push(p.clock.Now(), ui.QueuedToast[ui.Toast]{ID: n.Subject(), Duration: pickerNoticeLifetime, Value: ui.Toast{Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight}}) | |
| 755 | 810 | } | |
| 756 | 811 | ||
| 757 | 812 | // mergePickerOps accumulates two presentation decisions from one read. Close | |
| @@ -3,6 +3,7 @@ package client | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "slices" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -83,6 +84,8 @@ const ( | |||||
| 83 | pickerGeneration = uint64(1) | 84 | pickerGeneration = uint64(1) | ||
| 84 | // pickerNoticeLifetime bounds how long one catalogue notice is displayed. | 85 | // pickerNoticeLifetime bounds how long one catalogue notice is displayed. | ||
| 85 | pickerNoticeLifetime = 4 * time.Second | 86 | pickerNoticeLifetime = 4 * time.Second | ||
| 87 | + | // pickerNoticeVisible is how many notices stack at once; later ones wait. | |||
| 88 | + | pickerNoticeVisible = 3 | |||
| 86 | ) | 89 | ) | ||
| 87 | 90 | ||||
| 88 | // pickerController owns one client-owned picker: catalogue, model, renderer, | 91 | // pickerController owns one client-owned picker: catalogue, model, renderer, | ||
| @@ -92,12 +95,15 @@ type pickerController struct { | |||||
| 92 | clock ports.Clock | 95 | clock ports.Clock | ||
| 93 | catalogue *pickerCatalogue | 96 | catalogue *pickerCatalogue | ||
| 94 | 97 | ||||
| 95 | - | mu sync.Mutex | 98 | + | mu sync.Mutex |
| 96 | - | loop *pickerLoop | 99 | + | loop *pickerLoop |
| 97 | - | consumer pickerConsumer | 100 | + | consumer pickerConsumer |
| 98 | - | renderer *pickerRenderer | 101 | + | renderer *pickerRenderer |
| 99 | - | notices ui.ToastManager | 102 | + | notices *ui.ToastQueue[ui.Toast] |
| 100 | - | interaction uint64 | 103 | + | // noticeBounds are the toast boxes drawn last. The client cannot restore |
| 104 | + | // the session under them, so boxes that go away are blanked. | |||
| 105 | + | noticeBounds []domain.Rect | |||
| 106 | + | interaction uint64 | |||
| 101 | // sort is this client's picker ordering. It survives every picker | 107 | // sort is this client's picker ordering. It survives every picker | ||
| 102 | // reopen for the lifetime of the client process. | 108 | // reopen for the lifetime of the client process. | ||
| 103 | sort pickerusecase.SortMode | 109 | sort pickerusecase.SortMode | ||
| @@ -130,6 +136,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration, trueColor b | |||||
| 130 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | 136 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | ||
| 131 | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | 137 | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | ||
| 132 | sort: defaultPickerSort(), | 138 | sort: defaultPickerSort(), | ||
| 139 | + | notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}), | |||
| 133 | ownsInput: true, | 140 | ownsInput: true, | ||
| 134 | opsReady: make(chan struct{}, 1), | 141 | opsReady: make(chan struct{}, 1), | ||
| 135 | } | 142 | } | ||
| @@ -360,6 +367,8 @@ func (p *pickerController) invalidatePresentation() { | |||||
| 360 | } | 367 | } | ||
| 361 | p.mu.Lock() | 368 | p.mu.Lock() | ||
| 362 | p.renderer.invalidate() | 369 | p.renderer.invalidate() | ||
| 370 | + | // Another owner repainted the screen: the old toast cells are gone. | |||
| 371 | + | p.noticeBounds = nil | |||
| 363 | p.mu.Unlock() | 372 | p.mu.Unlock() | ||
| 364 | } | 373 | } | ||
| 365 | 374 | ||||
| @@ -385,7 +394,14 @@ func (p *pickerController) Render(size domain.Size) []byte { | |||||
| 385 | if p.loop == nil { | 394 | if p.loop == nil { | ||
| 386 | return nil | 395 | return nil | ||
| 387 | } | 396 | } | ||
| 388 | - | return p.renderer.render(p.loop, size, p.preview) | 397 | + | // Blank gone notices before the box is drawn, so a blank that overlaps |
| 398 | + | // the box never erases cells the picker renderer believes are on screen. | |||
| 399 | + | // A blank may also cover part of the box, so the box is then redrawn whole. | |||
| 400 | + | _, blanks := p.noticeStackLocked(size) | |||
| 401 | + | if len(blanks) != 0 { | |||
| 402 | + | p.renderer.invalidate() | |||
| 403 | + | } | |||
| 404 | + | return append(blanks, p.renderer.render(p.loop, size, p.preview)...) | |||
| 389 | } | 405 | } | ||
| 390 | 406 | ||||
| 391 | // SetPreview displays one preview frame in the given state. A frame is shown | 407 | // SetPreview displays one preview frame in the given state. A frame is shown | ||
| @@ -468,37 +484,75 @@ func (p *pickerController) ClearPreview() { | |||||
| 468 | p.mu.Unlock() | 484 | p.mu.Unlock() | ||
| 469 | } | 485 | } | ||
| 470 | 486 | ||||
| 471 | - | // RenderNotice composes the newest bounded catalogue notice as a client-local | 487 | + | // RenderNotice composes the live notice stack as client-local toasts: the |
| 472 | - | // toast. It reuses the client's existing toast rendering and returns nil when | 488 | + | // newest at the top-right corner, older ones below it, and any extra notices |
| 473 | - | // nothing is displayed. | 489 | + | // waiting for a free slot. Boxes drawn by the previous call that are gone now |
| 490 | + | // are blanked in the same payload. It returns nil when nothing changes on | |||
| 491 | + | // screen. | |||
| 474 | func (p *pickerController) RenderNotice(size domain.Size) []byte { | 492 | func (p *pickerController) RenderNotice(size domain.Size) []byte { | ||
| 475 | if p == nil { | 493 | if p == nil { | ||
| 476 | return nil | 494 | return nil | ||
| 477 | } | 495 | } | ||
| 478 | p.mu.Lock() | 496 | p.mu.Lock() | ||
| 479 | - | active := p.notices.Active(p.clock.Now()) | 497 | + | defer p.mu.Unlock() |
| 480 | - | p.mu.Unlock() | 498 | + | toasts, blanks := p.noticeStackLocked(size) |
| 481 | - | if len(active) == 0 { | 499 | + | if len(blanks) != 0 { |
| 482 | - | return nil | 500 | + | // A blank may cover picker box cells drawn by the previous Render. |
| 501 | + | p.renderer.invalidate() | |||
| 483 | } | 502 | } | ||
| 484 | - | // ToastManager.Active returns toasts in map-iteration order, so "the last | 503 | + | buffer := bytes.NewBuffer(blanks) |
| 485 | - | // one" is arbitrary and can change between frames. Select the newest by | 504 | + | for i, rect := range p.noticeBounds { |
| 486 | - | // ShownAt, tie-broken deterministically by ID. | 505 | + | _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity))) |
| 487 | - | newest := active[0] | |||
| 488 | - | for _, toast := range active[1:] { | |||
| 489 | - | if toast.ShownAt.After(newest.ShownAt) || | |||
| 490 | - | (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) { | |||
| 491 | - | newest = toast | |||
| 492 | - | } | |||
| 493 | } | 506 | } | ||
| 494 | - | if newest.Message == "" { | 507 | + | if buffer.Len() == 0 { |
| 495 | return nil | 508 | return nil | ||
| 496 | } | 509 | } | ||
| 510 | + | return buffer.Bytes() | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | // noticeStackLocked lays out the live notices for size and returns them with | |||
| 514 | + | // the bytes that blank boxes drawn before but no longer part of the stack. | |||
| 515 | + | // It records the new layout in noticeBounds. | |||
| 516 | + | func (p *pickerController) noticeStackLocked(size domain.Size) ([]ui.Toast, []byte) { | |||
| 517 | + | active := p.notices.Visible(p.clock.Now()) | |||
| 518 | + | toasts := make([]ui.Toast, len(active)) | |||
| 519 | + | for i, t := range active { | |||
| 520 | + | toasts[i] = t.Value | |||
| 521 | + | } | |||
| 522 | + | bounds := ui.ToastStackBounds(size, toasts) | |||
| 497 | var buffer bytes.Buffer | 523 | var buffer bytes.Buffer | ||
| 498 | - | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil { | 524 | + | for _, old := range p.noticeBounds { |
| 499 | - | return nil | 525 | + | if slices.Contains(bounds, old) { |
| 526 | + | continue | |||
| 527 | + | } | |||
| 528 | + | // After a shrink the old box may overhang the screen; writing past the | |||
| 529 | + | // edge would wrap onto unrelated cells. | |||
| 530 | + | if gone := clipRect(old, size); gone.Width > 0 && gone.Height > 0 { | |||
| 531 | + | _ = writeClientToast(&buffer, gone, blankToastLines(gone)) | |||
| 532 | + | } | |||
| 500 | } | 533 | } | ||
| 501 | - | return buffer.Bytes() | 534 | + | p.noticeBounds = bounds |
| 535 | + | return toasts[:len(bounds)], buffer.Bytes() | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | // clipRect is the part of r inside a size-sized screen. | |||
| 539 | + | func clipRect(r domain.Rect, size domain.Size) domain.Rect { | |||
| 540 | + | x0, y0 := max(r.X, 0), max(r.Y, 0) | |||
| 541 | + | x1, y1 := min(r.X+r.Width, size.Cols), min(r.Y+r.Height, size.Rows) | |||
| 542 | + | if x1 <= x0 || y1 <= y0 { | |||
| 543 | + | return domain.Rect{} | |||
| 544 | + | } | |||
| 545 | + | return domain.Rect{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0} | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // noticeDeadline is when the notice stack next changes on its own. | |||
| 549 | + | func (p *pickerController) noticeDeadline() (time.Time, bool) { | |||
| 550 | + | if p == nil { | |||
| 551 | + | return time.Time{}, false | |||
| 552 | + | } | |||
| 553 | + | p.mu.Lock() | |||
| 554 | + | defer p.mu.Unlock() | |||
| 555 | + | return p.notices.NextDeadline() | |||
| 502 | } | 556 | } | ||
| 503 | 557 | ||||
| 504 | // CursorKey reports the opaque key of the row under the cursor, or false when | 558 | // CursorKey reports the opaque key of the row under the cursor, or false when | ||
| @@ -620,6 +674,7 @@ func (p *pickerController) SetCurrent(current pickerCurrent) { | |||||
| 620 | p.loop = nil | 674 | p.loop = nil | ||
| 621 | p.rebuildLocked() | 675 | p.rebuildLocked() | ||
| 622 | p.renderer.invalidate() | 676 | p.renderer.invalidate() | ||
| 677 | + | p.noticeBounds = nil | |||
| 623 | } | 678 | } | ||
| 624 | 679 | ||||
| 625 | // rememberSortLocked keeps the sort the user chose in the closing | 680 | // rememberSortLocked keeps the sort the user chose in the closing | ||
| @@ -751,7 +806,7 @@ func (p *pickerController) notifyLocked(n domain.Notification) { | |||||
| 751 | if n.Message == "" { | 806 | if n.Message == "" { | ||
| 752 | return | 807 | return | ||
| 753 | } | 808 | } | ||
| 754 | - | p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | 809 | + | p.notices.Push(p.clock.Now(), ui.QueuedToast[ui.Toast]{ID: n.Subject(), Duration: pickerNoticeLifetime, Value: ui.Toast{Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight}}) |
| 755 | } | 810 | } | ||
| 756 | 811 | ||||
| 757 | // mergePickerOps accumulates two presentation decisions from one read. Close | 812 | // mergePickerOps accumulates two presentation decisions from one read. Close | ||
M internal/usecase/client/picker_controller_test.go +50 -24
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "io" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -579,11 +580,11 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||
| 579 | 580 | require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision()) | |
| 580 | 581 | ||
| 581 | 582 | controller.mu.Lock() | |
| 582 | - | active := controller.notices.Active(clock.Now()) | |
| 583 | + | active := controller.notices.Visible(clock.Now()) | |
| 583 | 584 | controller.mu.Unlock() | |
| 584 | 585 | require.Len(t, active, 1) | |
| 585 | - | require.Equal(t, "broker catalogue update rejected", active[0].Message) | |
| 586 | - | require.NotContains(t, active[0].Message, "user@") | |
| 586 | + | require.Equal(t, "broker catalogue update rejected", active[0].Value.Message) | |
| 587 | + | require.NotContains(t, active[0].Value.Message, "user@") | |
| 587 | 588 | } | |
| 588 | 589 | ||
| 589 | 590 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | |
| @@ -596,7 +597,7 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||
| 596 | 597 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | |
| 597 | 598 | ||
| 598 | 599 | controller.mu.Lock() | |
| 599 | - | active := controller.notices.Active(clock.Now()) | |
| 600 | + | active := controller.notices.Visible(clock.Now()) | |
| 600 | 601 | controller.mu.Unlock() | |
| 601 | 602 | require.Empty(t, active) | |
| 602 | 603 | lines := controller.Catalogue().Lines() | |
| @@ -606,26 +607,51 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||
| 606 | 607 | require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail) | |
| 607 | 608 | } | |
| 608 | 609 | ||
| 609 | - | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | |
| 610 | - | // the newest by ShownAt (tie-broken by ID), independent of the toast manager's | |
| 611 | - | // map-iteration order. | |
| 612 | - | func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) { | |
| 613 | - | controller, _ := pickerTestController(t) | |
| 610 | + | // TestPickerControllerRenderNoticeStacksQueuesAndBlanks pins the picker notice | |
| 611 | + | // stack: the newest notice sits at the top-right corner, a burst beyond the | |
| 612 | + | // visible slots waits for a free slot, and a box that goes away is blanked. | |
| 613 | + | func TestPickerControllerRenderNoticeStacksQueuesAndBlanks(t *testing.T) { | |
| 614 | 614 | size := domain.Size{Cols: 80, Rows: 24} | |
| 615 | - | ||
| 616 | - | controller.mu.Lock() | |
| 617 | - | controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"}) | |
| 618 | - | controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"}) | |
| 619 | - | controller.mu.Unlock() | |
| 620 | - | require.Contains(t, string(controller.RenderNotice(size)), "newer-message") | |
| 621 | - | ||
| 622 | - | // Equal ShownAt: the lexicographically greatest ID wins, deterministically. | |
| 623 | - | controller.mu.Lock() | |
| 624 | - | controller.notices.Clear() | |
| 625 | - | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"}) | |
| 626 | - | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"}) | |
| 627 | - | controller.mu.Unlock() | |
| 628 | - | for range 10 { | |
| 629 | - | require.Contains(t, string(controller.RenderNotice(size)), "from-bbb") | |
| 615 | + | notice := func(msg string) domain.Notification { | |
| 616 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeInfo, Message: msg, Scope: msg} | |
| 617 | + | } | |
| 618 | + | tests := []struct { | |
| 619 | + | name string | |
| 620 | + | notices []string | |
| 621 | + | advance time.Duration | |
| 622 | + | wantShown []string | |
| 623 | + | wantHidden []string | |
| 624 | + | wantBlanked bool | |
| 625 | + | }{ | |
| 626 | + | {name: "distinct subjects stack", notices: []string{"killed session 1", "killed session 2"}, wantShown: []string{"killed session 1", "killed session 2"}}, | |
| 627 | + | {name: "burst waits for a slot", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, wantShown: []string{"killed session 1", "killed session 2", "killed session 3"}, wantHidden: []string{"killed session 4"}}, | |
| 628 | + | {name: "expiry promotes the next notice", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, advance: pickerNoticeLifetime, wantShown: []string{"killed session 4"}, wantBlanked: true}, | |
| 629 | + | {name: "expiry of the last notice blanks it", notices: []string{"killed session 1"}, advance: pickerNoticeLifetime, wantHidden: []string{"killed session 1"}, wantBlanked: true}, | |
| 630 | + | } | |
| 631 | + | for _, tt := range tests { | |
| 632 | + | t.Run(tt.name, func(t *testing.T) { | |
| 633 | + | controller, clock := pickerTestController(t) | |
| 634 | + | for _, msg := range tt.notices { | |
| 635 | + | controller.Notify(notice(msg)) | |
| 636 | + | } | |
| 637 | + | first := string(controller.RenderNotice(size)) | |
| 638 | + | if tt.advance > 0 { | |
| 639 | + | clock.mu.Lock() | |
| 640 | + | clock.now = clock.now.Add(tt.advance) | |
| 641 | + | clock.mu.Unlock() | |
| 642 | + | } | |
| 643 | + | got := first | |
| 644 | + | if tt.advance > 0 { | |
| 645 | + | got = string(controller.RenderNotice(size)) | |
| 646 | + | } | |
| 647 | + | for _, msg := range tt.wantShown { | |
| 648 | + | require.Contains(t, got, msg) | |
| 649 | + | } | |
| 650 | + | for _, msg := range tt.wantHidden { | |
| 651 | + | require.NotContains(t, got, msg) | |
| 652 | + | } | |
| 653 | + | blank := strings.Repeat(" ", ui.ToastBounds(size, ui.Toast{Message: "killed session 1", Anchor: domain.AnchorTopRight}).Width) | |
| 654 | + | require.Equal(t, tt.wantBlanked, tt.advance > 0 && strings.Contains(got, blank)) | |
| 655 | + | }) | |
| 630 | 656 | } | |
| 631 | 657 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "strings" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -579,11 +580,11 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||||
| 579 | require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision()) | 580 | require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision()) | ||
| 580 | 581 | ||||
| 581 | controller.mu.Lock() | 582 | controller.mu.Lock() | ||
| 582 | - | active := controller.notices.Active(clock.Now()) | 583 | + | active := controller.notices.Visible(clock.Now()) |
| 583 | controller.mu.Unlock() | 584 | controller.mu.Unlock() | ||
| 584 | require.Len(t, active, 1) | 585 | require.Len(t, active, 1) | ||
| 585 | - | require.Equal(t, "broker catalogue update rejected", active[0].Message) | 586 | + | require.Equal(t, "broker catalogue update rejected", active[0].Value.Message) |
| 586 | - | require.NotContains(t, active[0].Message, "user@") | 587 | + | require.NotContains(t, active[0].Value.Message, "user@") |
| 587 | } | 588 | } | ||
| 588 | 589 | ||||
| 589 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | 590 | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | ||
| @@ -596,7 +597,7 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||||
| 596 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | 597 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | ||
| 597 | 598 | ||||
| 598 | controller.mu.Lock() | 599 | controller.mu.Lock() | ||
| 599 | - | active := controller.notices.Active(clock.Now()) | 600 | + | active := controller.notices.Visible(clock.Now()) |
| 600 | controller.mu.Unlock() | 601 | controller.mu.Unlock() | ||
| 601 | require.Empty(t, active) | 602 | require.Empty(t, active) | ||
| 602 | lines := controller.Catalogue().Lines() | 603 | lines := controller.Catalogue().Lines() | ||
| @@ -606,26 +607,51 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi | |||||
| 606 | require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail) | 607 | require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail) | ||
| 607 | } | 608 | } | ||
| 608 | 609 | ||||
| 609 | - | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | 610 | + | // TestPickerControllerRenderNoticeStacksQueuesAndBlanks pins the picker notice |
| 610 | - | // the newest by ShownAt (tie-broken by ID), independent of the toast manager's | 611 | + | // stack: the newest notice sits at the top-right corner, a burst beyond the |
| 611 | - | // map-iteration order. | 612 | + | // visible slots waits for a free slot, and a box that goes away is blanked. |
| 612 | - | func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) { | 613 | + | func TestPickerControllerRenderNoticeStacksQueuesAndBlanks(t *testing.T) { |
| 613 | - | controller, _ := pickerTestController(t) | |||
| 614 | size := domain.Size{Cols: 80, Rows: 24} | 614 | size := domain.Size{Cols: 80, Rows: 24} | ||
| 615 | - | 615 | + | notice := func(msg string) domain.Notification { | |
| 616 | - | controller.mu.Lock() | 616 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeInfo, Message: msg, Scope: msg} |
| 617 | - | controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"}) | 617 | + | } |
| 618 | - | controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"}) | 618 | + | tests := []struct { |
| 619 | - | controller.mu.Unlock() | 619 | + | name string |
| 620 | - | require.Contains(t, string(controller.RenderNotice(size)), "newer-message") | 620 | + | notices []string |
| 621 | - | 621 | + | advance time.Duration | |
| 622 | - | // Equal ShownAt: the lexicographically greatest ID wins, deterministically. | 622 | + | wantShown []string |
| 623 | - | controller.mu.Lock() | 623 | + | wantHidden []string |
| 624 | - | controller.notices.Clear() | 624 | + | wantBlanked bool |
| 625 | - | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"}) | 625 | + | }{ |
| 626 | - | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"}) | 626 | + | {name: "distinct subjects stack", notices: []string{"killed session 1", "killed session 2"}, wantShown: []string{"killed session 1", "killed session 2"}}, |
| 627 | - | controller.mu.Unlock() | 627 | + | {name: "burst waits for a slot", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, wantShown: []string{"killed session 1", "killed session 2", "killed session 3"}, wantHidden: []string{"killed session 4"}}, |
| 628 | - | for range 10 { | 628 | + | {name: "expiry promotes the next notice", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, advance: pickerNoticeLifetime, wantShown: []string{"killed session 4"}, wantBlanked: true}, |
| 629 | - | require.Contains(t, string(controller.RenderNotice(size)), "from-bbb") | 629 | + | {name: "expiry of the last notice blanks it", notices: []string{"killed session 1"}, advance: pickerNoticeLifetime, wantHidden: []string{"killed session 1"}, wantBlanked: true}, |
| 630 | + | } | |||
| 631 | + | for _, tt := range tests { | |||
| 632 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 633 | + | controller, clock := pickerTestController(t) | |||
| 634 | + | for _, msg := range tt.notices { | |||
| 635 | + | controller.Notify(notice(msg)) | |||
| 636 | + | } | |||
| 637 | + | first := string(controller.RenderNotice(size)) | |||
| 638 | + | if tt.advance > 0 { | |||
| 639 | + | clock.mu.Lock() | |||
| 640 | + | clock.now = clock.now.Add(tt.advance) | |||
| 641 | + | clock.mu.Unlock() | |||
| 642 | + | } | |||
| 643 | + | got := first | |||
| 644 | + | if tt.advance > 0 { | |||
| 645 | + | got = string(controller.RenderNotice(size)) | |||
| 646 | + | } | |||
| 647 | + | for _, msg := range tt.wantShown { | |||
| 648 | + | require.Contains(t, got, msg) | |||
| 649 | + | } | |||
| 650 | + | for _, msg := range tt.wantHidden { | |||
| 651 | + | require.NotContains(t, got, msg) | |||
| 652 | + | } | |||
| 653 | + | blank := strings.Repeat(" ", ui.ToastBounds(size, ui.Toast{Message: "killed session 1", Anchor: domain.AnchorTopRight}).Width) | |||
| 654 | + | require.Equal(t, tt.wantBlanked, tt.advance > 0 && strings.Contains(got, blank)) | |||
| 655 | + | }) | |||
| 630 | } | 656 | } | ||
| 631 | } | 657 | } | ||
M internal/usecase/client/picker_parity_test.go +12 -12
| @@ -525,8 +525,8 @@ func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) { | |||
| 525 | 525 | controller.mu.Lock() | |
| 526 | 526 | defer controller.mu.Unlock() | |
| 527 | 527 | messages := make([]string, 0) | |
| 528 | - | for _, toast := range controller.notices.Active(clock.Now()) { | |
| 529 | - | messages = append(messages, toast.Message) | |
| 528 | + | for _, toast := range controller.notices.Visible(clock.Now()) { | |
| 529 | + | messages = append(messages, toast.Value.Message) | |
| 530 | 530 | } | |
| 531 | 531 | require.Len(t, messages, 2, "each host keeps one toast") | |
| 532 | 532 | require.Contains(t, messages, "Remote host reconnected: user@arch") | |
| @@ -549,21 +549,21 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||
| 549 | 549 | return o | |
| 550 | 550 | } | |
| 551 | 551 | revision := ports.BrokerRevision(0) | |
| 552 | - | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast { | |
| 552 | + | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.QueuedToast[ui.Toast] { | |
| 553 | 553 | revision++ | |
| 554 | 554 | controller.mu.Lock() | |
| 555 | - | controller.notices.Clear() | |
| 555 | + | controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}) | |
| 556 | 556 | controller.mu.Unlock() | |
| 557 | 557 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons}) | |
| 558 | 558 | controller.mu.Lock() | |
| 559 | 559 | defer controller.mu.Unlock() | |
| 560 | - | return controller.notices.Active(clock.Now()) | |
| 560 | + | return controller.notices.Visible(clock.Now()) | |
| 561 | 561 | } | |
| 562 | 562 | ||
| 563 | 563 | toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)) | |
| 564 | 564 | require.Len(t, toasts, 1) | |
| 565 | - | require.Contains(t, toasts[0].Message, "SSH host verification failed") | |
| 566 | - | require.Contains(t, toasts[0].Message, "user@arch") | |
| 565 | + | require.Contains(t, toasts[0].Value.Message, "SSH host verification failed") | |
| 566 | + | require.Contains(t, toasts[0].Value.Message, "user@arch") | |
| 567 | 567 | require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once") | |
| 568 | 568 | require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again") | |
| 569 | 569 | ||
| @@ -571,7 +571,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||
| 571 | 571 | recovered.FailureEpisode = 2 | |
| 572 | 572 | toasts = apply(recovered) | |
| 573 | 573 | require.Len(t, toasts, 1, "a recovery toasts once") | |
| 574 | - | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message) | |
| 574 | + | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Value.Message) | |
| 575 | 575 | require.Empty(t, apply(recovered), "a steady healthy host is silent") | |
| 576 | 576 | ||
| 577 | 577 | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1) | |
| @@ -586,7 +586,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||
| 586 | 586 | ||
| 587 | 587 | toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | |
| 588 | 588 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | |
| 589 | - | require.Contains(t, toasts[0].Message, "user@mule") | |
| 589 | + | require.Contains(t, toasts[0].Value.Message, "user@mule") | |
| 590 | 590 | } | |
| 591 | 591 | ||
| 592 | 592 | // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a | |
| @@ -609,7 +609,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||
| 609 | 609 | o.Availability = tt.availability | |
| 610 | 610 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}}) | |
| 611 | 611 | controller.mu.Lock() | |
| 612 | - | controller.notices.Clear() | |
| 612 | + | controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}) | |
| 613 | 613 | controller.mu.Unlock() | |
| 614 | 614 | ||
| 615 | 615 | _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase()) | |
| @@ -619,10 +619,10 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||
| 619 | 619 | } | |
| 620 | 620 | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable)) | |
| 621 | 621 | controller.mu.Lock() | |
| 622 | - | active := controller.notices.Active(clock.Now()) | |
| 622 | + | active := controller.notices.Visible(clock.Now()) | |
| 623 | 623 | controller.mu.Unlock() | |
| 624 | 624 | require.Len(t, active, 1) | |
| 625 | - | require.Equal(t, tt.wantNotice, active[0].Message) | |
| 625 | + | require.Equal(t, tt.wantNotice, active[0].Value.Message) | |
| 626 | 626 | }) | |
| 627 | 627 | } | |
| 628 | 628 | } | |
| @@ -525,8 +525,8 @@ func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) { | |||||
| 525 | controller.mu.Lock() | 525 | controller.mu.Lock() | ||
| 526 | defer controller.mu.Unlock() | 526 | defer controller.mu.Unlock() | ||
| 527 | messages := make([]string, 0) | 527 | messages := make([]string, 0) | ||
| 528 | - | for _, toast := range controller.notices.Active(clock.Now()) { | 528 | + | for _, toast := range controller.notices.Visible(clock.Now()) { |
| 529 | - | messages = append(messages, toast.Message) | 529 | + | messages = append(messages, toast.Value.Message) |
| 530 | } | 530 | } | ||
| 531 | require.Len(t, messages, 2, "each host keeps one toast") | 531 | require.Len(t, messages, 2, "each host keeps one toast") | ||
| 532 | require.Contains(t, messages, "Remote host reconnected: user@arch") | 532 | require.Contains(t, messages, "Remote host reconnected: user@arch") | ||
| @@ -549,21 +549,21 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||||
| 549 | return o | 549 | return o | ||
| 550 | } | 550 | } | ||
| 551 | revision := ports.BrokerRevision(0) | 551 | revision := ports.BrokerRevision(0) | ||
| 552 | - | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast { | 552 | + | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.QueuedToast[ui.Toast] { |
| 553 | revision++ | 553 | revision++ | ||
| 554 | controller.mu.Lock() | 554 | controller.mu.Lock() | ||
| 555 | - | controller.notices.Clear() | 555 | + | controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}) |
| 556 | controller.mu.Unlock() | 556 | controller.mu.Unlock() | ||
| 557 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons}) | 557 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons}) | ||
| 558 | controller.mu.Lock() | 558 | controller.mu.Lock() | ||
| 559 | defer controller.mu.Unlock() | 559 | defer controller.mu.Unlock() | ||
| 560 | - | return controller.notices.Active(clock.Now()) | 560 | + | return controller.notices.Visible(clock.Now()) |
| 561 | } | 561 | } | ||
| 562 | 562 | ||||
| 563 | toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)) | 563 | toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)) | ||
| 564 | require.Len(t, toasts, 1) | 564 | require.Len(t, toasts, 1) | ||
| 565 | - | require.Contains(t, toasts[0].Message, "SSH host verification failed") | 565 | + | require.Contains(t, toasts[0].Value.Message, "SSH host verification failed") |
| 566 | - | require.Contains(t, toasts[0].Message, "user@arch") | 566 | + | require.Contains(t, toasts[0].Value.Message, "user@arch") |
| 567 | require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once") | 567 | require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once") | ||
| 568 | require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again") | 568 | require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again") | ||
| 569 | 569 | ||||
| @@ -571,7 +571,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||||
| 571 | recovered.FailureEpisode = 2 | 571 | recovered.FailureEpisode = 2 | ||
| 572 | toasts = apply(recovered) | 572 | toasts = apply(recovered) | ||
| 573 | require.Len(t, toasts, 1, "a recovery toasts once") | 573 | require.Len(t, toasts, 1, "a recovery toasts once") | ||
| 574 | - | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message) | 574 | + | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Value.Message) |
| 575 | require.Empty(t, apply(recovered), "a steady healthy host is silent") | 575 | require.Empty(t, apply(recovered), "a steady healthy host is silent") | ||
| 576 | 576 | ||||
| 577 | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1) | 577 | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1) | ||
| @@ -586,7 +586,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||||
| 586 | 586 | ||||
| 587 | toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | 587 | toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | ||
| 588 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | 588 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | ||
| 589 | - | require.Contains(t, toasts[0].Message, "user@mule") | 589 | + | require.Contains(t, toasts[0].Value.Message, "user@mule") |
| 590 | } | 590 | } | ||
| 591 | 591 | ||||
| 592 | // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a | 592 | // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a | ||
| @@ -609,7 +609,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||||
| 609 | o.Availability = tt.availability | 609 | o.Availability = tt.availability | ||
| 610 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}}) | 610 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}}) | ||
| 611 | controller.mu.Lock() | 611 | controller.mu.Lock() | ||
| 612 | - | controller.notices.Clear() | 612 | + | controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}) |
| 613 | controller.mu.Unlock() | 613 | controller.mu.Unlock() | ||
| 614 | 614 | ||||
| 615 | _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase()) | 615 | _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase()) | ||
| @@ -619,10 +619,10 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||||
| 619 | } | 619 | } | ||
| 620 | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable)) | 620 | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable)) | ||
| 621 | controller.mu.Lock() | 621 | controller.mu.Lock() | ||
| 622 | - | active := controller.notices.Active(clock.Now()) | 622 | + | active := controller.notices.Visible(clock.Now()) |
| 623 | controller.mu.Unlock() | 623 | controller.mu.Unlock() | ||
| 624 | require.Len(t, active, 1) | 624 | require.Len(t, active, 1) | ||
| 625 | - | require.Equal(t, tt.wantNotice, active[0].Message) | 625 | + | require.Equal(t, tt.wantNotice, active[0].Value.Message) |
| 626 | }) | 626 | }) | ||
| 627 | } | 627 | } | ||
| 628 | } | 628 | } | ||
M internal/usecase/client/supervisor.go +51 -0
| @@ -908,8 +908,12 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 908 | 908 | } | |
| 909 | 909 | ops = s.cfg.Picker.OpsReady() | |
| 910 | 910 | } | |
| 911 | + | notices := noticeWake{clock: s.cfg.Clock} | |
| 912 | + | defer notices.stop() | |
| 911 | 913 | for { | |
| 912 | 914 | select { | |
| 915 | + | case <-notices.arm(s.cfg.Picker): | |
| 916 | + | s.renderCurrent() | |
| 913 | 917 | case <-service.Done(): | |
| 914 | 918 | return readyOutcome{lossErr: service.Err()} | |
| 915 | 919 | case <-changed: | |
| @@ -1017,6 +1021,45 @@ func (s *Supervisor) notifyPicker(n domain.Notification) { | |||
| 1017 | 1021 | } | |
| 1018 | 1022 | } | |
| 1019 | 1023 | ||
| 1024 | + | // noticeDeadliner reports when the picker's notice stack next changes. | |
| 1025 | + | type noticeDeadliner interface { | |
| 1026 | + | noticeDeadline() (time.Time, bool) | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | // noticeWake wakes the ready loop when a picker notice expires, so the stack | |
| 1030 | + | // advances and freed cells are blanked without waiting for other input. | |
| 1031 | + | type noticeWake struct { | |
| 1032 | + | clock ports.Clock | |
| 1033 | + | timer ports.Timer | |
| 1034 | + | due time.Time | |
| 1035 | + | } | |
| 1036 | + | ||
| 1037 | + | // arm returns a channel that fires at the next notice deadline, or nil when | |
| 1038 | + | // no notice will change on its own. | |
| 1039 | + | func (w *noticeWake) arm(picker pickerHost) <-chan time.Time { | |
| 1040 | + | host, ok := picker.(noticeDeadliner) | |
| 1041 | + | if !ok || supervisorNil(w.clock) { | |
| 1042 | + | return nil | |
| 1043 | + | } | |
| 1044 | + | due, ok := host.noticeDeadline() | |
| 1045 | + | if !ok { | |
| 1046 | + | w.stop() | |
| 1047 | + | return nil | |
| 1048 | + | } | |
| 1049 | + | if w.timer == nil || !due.Equal(w.due) { | |
| 1050 | + | w.stop() | |
| 1051 | + | w.timer = w.clock.NewTimer(max(due.Sub(w.clock.Now()), 0)) | |
| 1052 | + | w.due = due | |
| 1053 | + | } | |
| 1054 | + | return w.timer.C() | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | func (w *noticeWake) stop() { | |
| 1058 | + | stopSupervisorTimer(w.timer) | |
| 1059 | + | w.timer = nil | |
| 1060 | + | w.due = time.Time{} | |
| 1061 | + | } | |
| 1062 | + | ||
| 1020 | 1063 | // pickerExitHint tells an unattached user how to leave the picker. | |
| 1021 | 1064 | var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"} | |
| 1022 | 1065 | ||
| @@ -1032,6 +1075,8 @@ func (s *Supervisor) invalidatePickerPresentation() { | |||
| 1032 | 1075 | // failure until the terminal ends or the process is cancelled. A clean terminal | |
| 1033 | 1076 | // EOF returns nil; every other cause is reported. | |
| 1034 | 1077 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | |
| 1078 | + | notices := noticeWake{clock: s.cfg.Clock} | |
| 1079 | + | defer notices.stop() | |
| 1035 | 1080 | for { | |
| 1036 | 1081 | select { | |
| 1037 | 1082 | case <-s.pickerOps(): | |
| @@ -1044,6 +1089,8 @@ func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputL | |||
| 1044 | 1089 | return terminalReadCause(err) | |
| 1045 | 1090 | case <-s.presentationInvalidation(): | |
| 1046 | 1091 | s.renderResizeInvalidation() | |
| 1092 | + | case <-notices.arm(s.cfg.Picker): | |
| 1093 | + | s.renderCurrent() | |
| 1047 | 1094 | } | |
| 1048 | 1095 | } | |
| 1049 | 1096 | } | |
| @@ -1058,6 +1105,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||
| 1058 | 1105 | } | |
| 1059 | 1106 | timer := s.cfg.Clock.NewTimer(delay) | |
| 1060 | 1107 | defer stopSupervisorTimer(timer) | |
| 1108 | + | notices := noticeWake{clock: s.cfg.Clock} | |
| 1109 | + | defer notices.stop() | |
| 1061 | 1110 | for { | |
| 1062 | 1111 | select { | |
| 1063 | 1112 | case <-s.pickerOps(): | |
| @@ -1074,6 +1123,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||
| 1074 | 1123 | s.renderResizeInvalidation() | |
| 1075 | 1124 | case <-s.spinnerTick(): | |
| 1076 | 1125 | s.cfg.Spinner.AdvanceSpinner(s.State()) | |
| 1126 | + | case <-notices.arm(s.cfg.Picker): | |
| 1127 | + | s.renderCurrent() | |
| 1077 | 1128 | } | |
| 1078 | 1129 | } | |
| 1079 | 1130 | } | |
| @@ -908,8 +908,12 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 908 | } | 908 | } | ||
| 909 | ops = s.cfg.Picker.OpsReady() | 909 | ops = s.cfg.Picker.OpsReady() | ||
| 910 | } | 910 | } | ||
| 911 | + | notices := noticeWake{clock: s.cfg.Clock} | |||
| 912 | + | defer notices.stop() | |||
| 911 | for { | 913 | for { | ||
| 912 | select { | 914 | select { | ||
| 915 | + | case <-notices.arm(s.cfg.Picker): | |||
| 916 | + | s.renderCurrent() | |||
| 913 | case <-service.Done(): | 917 | case <-service.Done(): | ||
| 914 | return readyOutcome{lossErr: service.Err()} | 918 | return readyOutcome{lossErr: service.Err()} | ||
| 915 | case <-changed: | 919 | case <-changed: | ||
| @@ -1017,6 +1021,45 @@ func (s *Supervisor) notifyPicker(n domain.Notification) { | |||||
| 1017 | } | 1021 | } | ||
| 1018 | } | 1022 | } | ||
| 1019 | 1023 | ||||
| 1024 | + | // noticeDeadliner reports when the picker's notice stack next changes. | |||
| 1025 | + | type noticeDeadliner interface { | |||
| 1026 | + | noticeDeadline() (time.Time, bool) | |||
| 1027 | + | } | |||
| 1028 | + | ||||
| 1029 | + | // noticeWake wakes the ready loop when a picker notice expires, so the stack | |||
| 1030 | + | // advances and freed cells are blanked without waiting for other input. | |||
| 1031 | + | type noticeWake struct { | |||
| 1032 | + | clock ports.Clock | |||
| 1033 | + | timer ports.Timer | |||
| 1034 | + | due time.Time | |||
| 1035 | + | } | |||
| 1036 | + | ||||
| 1037 | + | // arm returns a channel that fires at the next notice deadline, or nil when | |||
| 1038 | + | // no notice will change on its own. | |||
| 1039 | + | func (w *noticeWake) arm(picker pickerHost) <-chan time.Time { | |||
| 1040 | + | host, ok := picker.(noticeDeadliner) | |||
| 1041 | + | if !ok || supervisorNil(w.clock) { | |||
| 1042 | + | return nil | |||
| 1043 | + | } | |||
| 1044 | + | due, ok := host.noticeDeadline() | |||
| 1045 | + | if !ok { | |||
| 1046 | + | w.stop() | |||
| 1047 | + | return nil | |||
| 1048 | + | } | |||
| 1049 | + | if w.timer == nil || !due.Equal(w.due) { | |||
| 1050 | + | w.stop() | |||
| 1051 | + | w.timer = w.clock.NewTimer(max(due.Sub(w.clock.Now()), 0)) | |||
| 1052 | + | w.due = due | |||
| 1053 | + | } | |||
| 1054 | + | return w.timer.C() | |||
| 1055 | + | } | |||
| 1056 | + | ||||
| 1057 | + | func (w *noticeWake) stop() { | |||
| 1058 | + | stopSupervisorTimer(w.timer) | |||
| 1059 | + | w.timer = nil | |||
| 1060 | + | w.due = time.Time{} | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1020 | // pickerExitHint tells an unattached user how to leave the picker. | 1063 | // pickerExitHint tells an unattached user how to leave the picker. | ||
| 1021 | var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"} | 1064 | var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"} | ||
| 1022 | 1065 | ||||
| @@ -1032,6 +1075,8 @@ func (s *Supervisor) invalidatePickerPresentation() { | |||||
| 1032 | // failure until the terminal ends or the process is cancelled. A clean terminal | 1075 | // failure until the terminal ends or the process is cancelled. A clean terminal | ||
| 1033 | // EOF returns nil; every other cause is reported. | 1076 | // EOF returns nil; every other cause is reported. | ||
| 1034 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | 1077 | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | ||
| 1078 | + | notices := noticeWake{clock: s.cfg.Clock} | |||
| 1079 | + | defer notices.stop() | |||
| 1035 | for { | 1080 | for { | ||
| 1036 | select { | 1081 | select { | ||
| 1037 | case <-s.pickerOps(): | 1082 | case <-s.pickerOps(): | ||
| @@ -1044,6 +1089,8 @@ func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputL | |||||
| 1044 | return terminalReadCause(err) | 1089 | return terminalReadCause(err) | ||
| 1045 | case <-s.presentationInvalidation(): | 1090 | case <-s.presentationInvalidation(): | ||
| 1046 | s.renderResizeInvalidation() | 1091 | s.renderResizeInvalidation() | ||
| 1092 | + | case <-notices.arm(s.cfg.Picker): | |||
| 1093 | + | s.renderCurrent() | |||
| 1047 | } | 1094 | } | ||
| 1048 | } | 1095 | } | ||
| 1049 | } | 1096 | } | ||
| @@ -1058,6 +1105,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||||
| 1058 | } | 1105 | } | ||
| 1059 | timer := s.cfg.Clock.NewTimer(delay) | 1106 | timer := s.cfg.Clock.NewTimer(delay) | ||
| 1060 | defer stopSupervisorTimer(timer) | 1107 | defer stopSupervisorTimer(timer) | ||
| 1108 | + | notices := noticeWake{clock: s.cfg.Clock} | |||
| 1109 | + | defer notices.stop() | |||
| 1061 | for { | 1110 | for { | ||
| 1062 | select { | 1111 | select { | ||
| 1063 | case <-s.pickerOps(): | 1112 | case <-s.pickerOps(): | ||
| @@ -1074,6 +1123,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||||
| 1074 | s.renderResizeInvalidation() | 1123 | s.renderResizeInvalidation() | ||
| 1075 | case <-s.spinnerTick(): | 1124 | case <-s.spinnerTick(): | ||
| 1076 | s.cfg.Spinner.AdvanceSpinner(s.State()) | 1125 | s.cfg.Spinner.AdvanceSpinner(s.State()) | ||
| 1126 | + | case <-notices.arm(s.cfg.Picker): | |||
| 1127 | + | s.renderCurrent() | |||
| 1077 | } | 1128 | } | ||
| 1078 | } | 1129 | } | ||
| 1079 | } | 1130 | } | ||
M internal/usecase/client/supervisor_attach.go +4 -0
| @@ -731,6 +731,8 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||
| 731 | 731 | var brokerLost bool | |
| 732 | 732 | var terminated bool | |
| 733 | 733 | var termErr error | |
| 734 | + | notices := noticeWake{clock: s.cfg.Clock} | |
| 735 | + | defer notices.stop() | |
| 734 | 736 | settlement: | |
| 735 | 737 | for { | |
| 736 | 738 | // Begin has admitted the foreground, which can write before MarkAttached | |
| @@ -762,6 +764,8 @@ settlement: | |||
| 762 | 764 | overlay.settleAction(consumed) | |
| 763 | 765 | case <-overlay.invalidation(): | |
| 764 | 766 | overlay.resize() | |
| 767 | + | case <-notices.arm(s.cfg.Picker): | |
| 768 | + | s.renderCurrent() | |
| 765 | 769 | case outcome := <-s.kills.results(): | |
| 766 | 770 | s.finishPickerKill(service, outcome) | |
| 767 | 771 | case <-service.Done(): | |
| @@ -731,6 +731,8 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||||
| 731 | var brokerLost bool | 731 | var brokerLost bool | ||
| 732 | var terminated bool | 732 | var terminated bool | ||
| 733 | var termErr error | 733 | var termErr error | ||
| 734 | + | notices := noticeWake{clock: s.cfg.Clock} | |||
| 735 | + | defer notices.stop() | |||
| 734 | settlement: | 736 | settlement: | ||
| 735 | for { | 737 | for { | ||
| 736 | // Begin has admitted the foreground, which can write before MarkAttached | 738 | // Begin has admitted the foreground, which can write before MarkAttached | ||
| @@ -762,6 +764,8 @@ settlement: | |||||
| 762 | overlay.settleAction(consumed) | 764 | overlay.settleAction(consumed) | ||
| 763 | case <-overlay.invalidation(): | 765 | case <-overlay.invalidation(): | ||
| 764 | overlay.resize() | 766 | overlay.resize() | ||
| 767 | + | case <-notices.arm(s.cfg.Picker): | |||
| 768 | + | s.renderCurrent() | |||
| 765 | case outcome := <-s.kills.results(): | 769 | case outcome := <-s.kills.results(): | ||
| 766 | s.finishPickerKill(service, outcome) | 770 | s.finishPickerKill(service, outcome) | ||
| 767 | case <-service.Done(): | 771 | case <-service.Done(): | ||
M internal/usecase/client/supervisor_picker_kill.go +3 -1
| @@ -139,7 +139,9 @@ func pickerKillNotice(outcome pickerKillOutcome) domain.Notification { | |||
| 139 | 139 | if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { | |
| 140 | 140 | sev = domain.NoticeInfo | |
| 141 | 141 | } | |
| 142 | - | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)} | |
| 142 | + | // Each target is its own subject, so kills in a row stack instead of | |
| 143 | + | // replacing each other. | |
| 144 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome), Scope: outcome.target.name} | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | func pickerKillText(outcome pickerKillOutcome) string { | |
| @@ -139,7 +139,9 @@ func pickerKillNotice(outcome pickerKillOutcome) domain.Notification { | |||||
| 139 | if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { | 139 | if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { | ||
| 140 | sev = domain.NoticeInfo | 140 | sev = domain.NoticeInfo | ||
| 141 | } | 141 | } | ||
| 142 | - | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)} | 142 | + | // Each target is its own subject, so kills in a row stack instead of |
| 143 | + | // replacing each other. | |||
| 144 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome), Scope: outcome.target.name} | |||
| 143 | } | 145 | } | ||
| 144 | 146 | ||||
| 145 | func pickerKillText(outcome pickerKillOutcome) string { | 147 | func pickerKillText(outcome pickerKillOutcome) string { | ||
M internal/usecase/daemon/graphics_output_test.go +2 -3
| @@ -346,9 +346,8 @@ func TestUnsupportedGraphicsWarningIsBoundedForLateSceneCreation(t *testing.T) { | |||
| 346 | 346 | ||
| 347 | 347 | require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression") | |
| 348 | 348 | require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment") | |
| 349 | - | ac.overlays.noticeMu.Lock() | |
| 350 | - | require.Len(t, ac.overlays.noticeToasts, 1) | |
| 351 | - | ac.overlays.noticeMu.Unlock() | |
| 349 | + | toasts, _ := visibleToasts(ac) | |
| 350 | + | require.Len(t, toasts, 1) | |
| 352 | 351 | } | |
| 353 | 352 | ||
| 354 | 353 | func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) { | |
| @@ -346,9 +346,8 @@ func TestUnsupportedGraphicsWarningIsBoundedForLateSceneCreation(t *testing.T) { | |||||
| 346 | 346 | ||||
| 347 | require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression") | 347 | require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression") | ||
| 348 | require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment") | 348 | require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment") | ||
| 349 | - | ac.overlays.noticeMu.Lock() | 349 | + | toasts, _ := visibleToasts(ac) |
| 350 | - | require.Len(t, ac.overlays.noticeToasts, 1) | 350 | + | require.Len(t, toasts, 1) |
| 351 | - | ac.overlays.noticeMu.Unlock() | |||
| 352 | } | 351 | } | ||
| 353 | 352 | ||||
| 354 | func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) { | 353 | func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) { | ||
M internal/usecase/daemon/notify.go +51 -86
| @@ -4,19 +4,21 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | const ( | |
| 16 | 18 | noticeHistoryCap = 200 | |
| 17 | 19 | noticePendingCap = 32 | |
| 18 | - | // maxVisibleToasts bounds the stack drawn over a client's screen; anything | |
| 19 | - | // trimmed is counted in noticeOverflow and stays reachable in history. | |
| 20 | + | // maxVisibleToasts bounds the stack drawn over a client's screen; later | |
| 21 | + | // notices wait for a free slot and are counted as "+N more" meanwhile. | |
| 20 | 22 | maxVisibleToasts = 3 | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| @@ -135,14 +137,6 @@ func (nc *noticeCenter) drainPending() []domain.Notification { | |||
| 135 | 137 | return out | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 138 | - | // noticeToast is one entry in a client's visible toast stack. seq identifies the | |
| 139 | - | // entry across coalescing so a stale expiry timer removes only its own toast. | |
| 140 | - | type noticeToast struct { | |
| 141 | - | n domain.Notification | |
| 142 | - | seq uint64 | |
| 143 | - | timer pendingByteTimer | |
| 144 | - | } | |
| 145 | - | ||
| 146 | 140 | // noticeTTL is how long a toast of this severity stays visible. | |
| 147 | 141 | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |
| 148 | 142 | switch sev { | |
| @@ -387,6 +381,8 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||
| 387 | 381 | // publishToast durably mutates one client's toast state without repainting. | |
| 388 | 382 | // Routing paths call it while routingMu excludes detach, then release routingMu | |
| 389 | 383 | // before rendering so a render failure can safely route another notice. | |
| 384 | + | // A notice matching a visible or waiting one (sameToastNotice) adds to its | |
| 385 | + | // count and restarts its lifetime; otherwise it takes a free slot or waits. | |
| 390 | 386 | func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | |
| 391 | 387 | if ac == nil { | |
| 392 | 388 | return false | |
| @@ -396,66 +392,37 @@ func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | |||
| 396 | 392 | ||
| 397 | 393 | rt.noticeMu.Lock() | |
| 398 | 394 | defer rt.noticeMu.Unlock() | |
| 399 | - | if i := rt.indexOfMatchingToastLocked(n); i >= 0 { | |
| 400 | - | rt.noticeToasts[i].n.Count += n.Count | |
| 401 | - | rt.noticeToasts[i].n.Time = n.Time | |
| 402 | - | rt.noticeToasts[i].timer.stop() | |
| 403 | - | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |
| 404 | - | return true | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | toast := noticeToast{n: n} | |
| 408 | - | rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...) | |
| 409 | - | if len(rt.noticeToasts) > maxVisibleToasts { | |
| 410 | - | for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ { | |
| 411 | - | rt.noticeToasts[i].timer.stop() | |
| 412 | - | rt.noticeOverflow++ | |
| 413 | - | } | |
| 414 | - | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |
| 395 | + | if rt.noticeQueue == nil { | |
| 396 | + | rt.noticeQueue = ui.NewToastQueue[domain.Notification](ui.ToastQueueOptions{MaxVisible: maxVisibleToasts, MaxPending: noticePendingCap}) | |
| 415 | 397 | } | |
| 416 | - | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | |
| 417 | - | return true | |
| 418 | - | } | |
| 419 | - | ||
| 420 | - | func sameToastNotice(a, b domain.Notification) bool { | |
| 421 | - | if a.Code != b.Code || a.SessionID != b.SessionID { | |
| 422 | - | return false | |
| 423 | - | } | |
| 424 | - | // Generic user notices and endpoint-scoped remote observations use their | |
| 425 | - | // visible message and details as identity. Other typed daemon notices retain | |
| 426 | - | // their established code-and-scope coalescing behavior. | |
| 427 | - | if a.Code != domain.NoticeUser && a.Code != domain.NoticeRemoteObservation { | |
| 428 | - | return true | |
| 398 | + | id := toastNoticeID(n) | |
| 399 | + | if prev, ok := rt.noticeQueue.Get(id); ok { | |
| 400 | + | prev.Value.Count += n.Count | |
| 401 | + | prev.Value.Time = n.Time | |
| 402 | + | n = prev.Value | |
| 429 | 403 | } | |
| 430 | - | return a.Severity == b.Severity && | |
| 431 | - | a.Message == b.Message && | |
| 432 | - | a.Details == b.Details | |
| 404 | + | rt.noticeQueue.Push(d.clock.Now(), ui.QueuedToast[domain.Notification]{ID: id, Value: n, Duration: noticeTTL(n.Severity)}) | |
| 405 | + | d.armToastTimerLocked(ac) | |
| 406 | + | return true | |
| 433 | 407 | } | |
| 434 | 408 | ||
| 435 | - | // indexOfMatchingToastLocked finds a visible toast with the code-specific | |
| 436 | - | // coalescing identity. Count and time are occurrence metadata, not identity. | |
| 437 | - | // Callers must hold noticeMu. | |
| 438 | - | func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int { | |
| 439 | - | for i := range rt.noticeToasts { | |
| 440 | - | if sameToastNotice(rt.noticeToasts[i].n, n) { | |
| 441 | - | return i | |
| 442 | - | } | |
| 409 | + | // toastNoticeID is a notice's coalescing identity. Generic user notices and | |
| 410 | + | // endpoint-scoped remote observations use their visible message and details; | |
| 411 | + | // other typed daemon notices coalesce by code and session. | |
| 412 | + | func toastNoticeID(n domain.Notification) string { | |
| 413 | + | id := strconv.Itoa(int(n.Code)) + "\x00" + string(n.SessionID) | |
| 414 | + | if n.Code != domain.NoticeUser && n.Code != domain.NoticeRemoteObservation { | |
| 415 | + | return id | |
| 443 | 416 | } | |
| 444 | - | return -1 | |
| 417 | + | return id + "\x00" + strconv.Itoa(int(n.Severity)) + "\x00" + n.Message + "\x00" + n.Details | |
| 445 | 418 | } | |
| 446 | 419 | ||
| 447 | - | // indexOfToastLocked finds a visible toast with the same code and scope. | |
| 448 | - | // Callers must hold noticeMu. | |
| 449 | - | func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int { | |
| 450 | - | for i := range rt.noticeToasts { | |
| 451 | - | if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid { | |
| 452 | - | return i | |
| 453 | - | } | |
| 454 | - | } | |
| 455 | - | return -1 | |
| 420 | + | // sameToastNotice reports whether a and b coalesce into one toast. | |
| 421 | + | func sameToastNotice(a, b domain.Notification) bool { | |
| 422 | + | return toastNoticeID(a) == toastNoticeID(b) | |
| 456 | 423 | } | |
| 457 | 424 | ||
| 458 | - | // dismissToast removes only the specified visible toast. In particular, a | |
| 425 | + | // dismissToastWithoutRepaint removes only the specified toast. In particular, a | |
| 459 | 426 | // link-connected event must not clear clipboard or other notice state. | |
| 460 | 427 | func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool { | |
| 461 | 428 | if ac == nil || ac.overlays == nil { | |
| @@ -464,38 +431,36 @@ func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.Noti | |||
| 464 | 431 | rt := ac.overlays | |
| 465 | 432 | rt.noticeMu.Lock() | |
| 466 | 433 | defer rt.noticeMu.Unlock() | |
| 467 | - | i := rt.indexOfToastLocked(code, sid) | |
| 468 | - | if i < 0 { | |
| 434 | + | if rt.noticeQueue == nil || !rt.noticeQueue.Dismiss(d.clock.Now(), toastNoticeID(domain.Notification{Code: code, SessionID: sid})) { | |
| 469 | 435 | return false | |
| 470 | 436 | } | |
| 471 | - | rt.noticeToasts[i].timer.stop() | |
| 472 | - | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |
| 473 | - | if len(rt.noticeToasts) == 0 { | |
| 474 | - | rt.noticeOverflow = 0 | |
| 475 | - | } | |
| 437 | + | d.armToastTimerLocked(ac) | |
| 476 | 438 | return true | |
| 477 | 439 | } | |
| 478 | 440 | ||
| 479 | - | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |
| 480 | - | // timer goroutine re-acquires it and releases it before repainting, so the | |
| 481 | - | // callback never runs with noticeMu held. | |
| 482 | - | func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | |
| 441 | + | // armToastTimerLocked points the client's single toast timer at the queue's | |
| 442 | + | // next expiry. Callers must hold noticeMu; the timer goroutine re-acquires it | |
| 443 | + | // and releases it before repainting, so the callback never runs with noticeMu | |
| 444 | + | // held. | |
| 445 | + | func (d *Daemon) armToastTimerLocked(ac *attachedClient) { | |
| 483 | 446 | rt := ac.overlays | |
| 484 | - | rt.noticeSeq++ | |
| 485 | - | t.seq = rt.noticeSeq | |
| 486 | - | seq := t.seq | |
| 487 | - | t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) { | |
| 447 | + | due, ok := rt.noticeQueue.NextDeadline() | |
| 448 | + | if ok && due.Equal(rt.noticeDue) && rt.noticeTimer.timer != nil { | |
| 449 | + | return | |
| 450 | + | } | |
| 451 | + | rt.noticeTimer.stop() | |
| 452 | + | rt.noticeDue = time.Time{} | |
| 453 | + | if !ok { | |
| 454 | + | return | |
| 455 | + | } | |
| 456 | + | rt.noticeDue = due | |
| 457 | + | rt.noticeTimer.retain(d.clock, max(due.Sub(d.clock.Now()), 0), func(fired ports.Timer) { | |
| 488 | 458 | rt.noticeMu.Lock() | |
| 489 | - | kept := rt.noticeToasts[:0] | |
| 490 | - | for _, tt := range rt.noticeToasts { | |
| 491 | - | if tt.seq == seq { | |
| 492 | - | continue | |
| 493 | - | } | |
| 494 | - | kept = append(kept, tt) | |
| 495 | - | } | |
| 496 | - | rt.noticeToasts = kept | |
| 497 | - | if len(rt.noticeToasts) == 0 { | |
| 498 | - | rt.noticeOverflow = 0 | |
| 459 | + | rt.noticeQueue.Visible(d.clock.Now()) | |
| 460 | + | // A fire that lost the race to a re-arm leaves the newer timer alone. | |
| 461 | + | if rt.noticeTimer.timer == fired { | |
| 462 | + | rt.noticeTimer.timer, rt.noticeTimer.done = nil, nil | |
| 463 | + | d.armToastTimerLocked(ac) | |
| 499 | 464 | } | |
| 500 | 465 | rt.noticeMu.Unlock() | |
| 501 | 466 | d.repaintForNotice(ac) | |
| @@ -4,19 +4,21 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "strconv" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | const ( | 17 | const ( | ||
| 16 | noticeHistoryCap = 200 | 18 | noticeHistoryCap = 200 | ||
| 17 | noticePendingCap = 32 | 19 | noticePendingCap = 32 | ||
| 18 | - | // maxVisibleToasts bounds the stack drawn over a client's screen; anything | 20 | + | // maxVisibleToasts bounds the stack drawn over a client's screen; later |
| 19 | - | // trimmed is counted in noticeOverflow and stays reachable in history. | 21 | + | // notices wait for a free slot and are counted as "+N more" meanwhile. |
| 20 | maxVisibleToasts = 3 | 22 | maxVisibleToasts = 3 | ||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| @@ -135,14 +137,6 @@ func (nc *noticeCenter) drainPending() []domain.Notification { | |||||
| 135 | return out | 137 | return out | ||
| 136 | } | 138 | } | ||
| 137 | 139 | ||||
| 138 | - | // noticeToast is one entry in a client's visible toast stack. seq identifies the | |||
| 139 | - | // entry across coalescing so a stale expiry timer removes only its own toast. | |||
| 140 | - | type noticeToast struct { | |||
| 141 | - | n domain.Notification | |||
| 142 | - | seq uint64 | |||
| 143 | - | timer pendingByteTimer | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | // noticeTTL is how long a toast of this severity stays visible. | 140 | // noticeTTL is how long a toast of this severity stays visible. | ||
| 147 | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | 141 | func noticeTTL(sev domain.NoticeSeverity) time.Duration { | ||
| 148 | switch sev { | 142 | switch sev { | ||
| @@ -387,6 +381,8 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||||
| 387 | // publishToast durably mutates one client's toast state without repainting. | 381 | // publishToast durably mutates one client's toast state without repainting. | ||
| 388 | // Routing paths call it while routingMu excludes detach, then release routingMu | 382 | // Routing paths call it while routingMu excludes detach, then release routingMu | ||
| 389 | // before rendering so a render failure can safely route another notice. | 383 | // before rendering so a render failure can safely route another notice. | ||
| 384 | + | // A notice matching a visible or waiting one (sameToastNotice) adds to its | |||
| 385 | + | // count and restarts its lifetime; otherwise it takes a free slot or waits. | |||
| 390 | func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | 386 | func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | ||
| 391 | if ac == nil { | 387 | if ac == nil { | ||
| 392 | return false | 388 | return false | ||
| @@ -396,66 +392,37 @@ func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | |||||
| 396 | 392 | ||||
| 397 | rt.noticeMu.Lock() | 393 | rt.noticeMu.Lock() | ||
| 398 | defer rt.noticeMu.Unlock() | 394 | defer rt.noticeMu.Unlock() | ||
| 399 | - | if i := rt.indexOfMatchingToastLocked(n); i >= 0 { | 395 | + | if rt.noticeQueue == nil { |
| 400 | - | rt.noticeToasts[i].n.Count += n.Count | 396 | + | rt.noticeQueue = ui.NewToastQueue[domain.Notification](ui.ToastQueueOptions{MaxVisible: maxVisibleToasts, MaxPending: noticePendingCap}) |
| 401 | - | rt.noticeToasts[i].n.Time = n.Time | |||
| 402 | - | rt.noticeToasts[i].timer.stop() | |||
| 403 | - | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |||
| 404 | - | return true | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | toast := noticeToast{n: n} | |||
| 408 | - | rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...) | |||
| 409 | - | if len(rt.noticeToasts) > maxVisibleToasts { | |||
| 410 | - | for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ { | |||
| 411 | - | rt.noticeToasts[i].timer.stop() | |||
| 412 | - | rt.noticeOverflow++ | |||
| 413 | - | } | |||
| 414 | - | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |||
| 415 | } | 397 | } | ||
| 416 | - | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | 398 | + | id := toastNoticeID(n) |
| 417 | - | return true | 399 | + | if prev, ok := rt.noticeQueue.Get(id); ok { |
| 418 | - | } | 400 | + | prev.Value.Count += n.Count |
| 419 | - | 401 | + | prev.Value.Time = n.Time | |
| 420 | - | func sameToastNotice(a, b domain.Notification) bool { | 402 | + | n = prev.Value |
| 421 | - | if a.Code != b.Code || a.SessionID != b.SessionID { | |||
| 422 | - | return false | |||
| 423 | - | } | |||
| 424 | - | // Generic user notices and endpoint-scoped remote observations use their | |||
| 425 | - | // visible message and details as identity. Other typed daemon notices retain | |||
| 426 | - | // their established code-and-scope coalescing behavior. | |||
| 427 | - | if a.Code != domain.NoticeUser && a.Code != domain.NoticeRemoteObservation { | |||
| 428 | - | return true | |||
| 429 | } | 403 | } | ||
| 430 | - | return a.Severity == b.Severity && | 404 | + | rt.noticeQueue.Push(d.clock.Now(), ui.QueuedToast[domain.Notification]{ID: id, Value: n, Duration: noticeTTL(n.Severity)}) |
| 431 | - | a.Message == b.Message && | 405 | + | d.armToastTimerLocked(ac) |
| 432 | - | a.Details == b.Details | 406 | + | return true |
| 433 | } | 407 | } | ||
| 434 | 408 | ||||
| 435 | - | // indexOfMatchingToastLocked finds a visible toast with the code-specific | 409 | + | // toastNoticeID is a notice's coalescing identity. Generic user notices and |
| 436 | - | // coalescing identity. Count and time are occurrence metadata, not identity. | 410 | + | // endpoint-scoped remote observations use their visible message and details; |
| 437 | - | // Callers must hold noticeMu. | 411 | + | // other typed daemon notices coalesce by code and session. |
| 438 | - | func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int { | 412 | + | func toastNoticeID(n domain.Notification) string { |
| 439 | - | for i := range rt.noticeToasts { | 413 | + | id := strconv.Itoa(int(n.Code)) + "\x00" + string(n.SessionID) |
| 440 | - | if sameToastNotice(rt.noticeToasts[i].n, n) { | 414 | + | if n.Code != domain.NoticeUser && n.Code != domain.NoticeRemoteObservation { |
| 441 | - | return i | 415 | + | return id |
| 442 | - | } | |||
| 443 | } | 416 | } | ||
| 444 | - | return -1 | 417 | + | return id + "\x00" + strconv.Itoa(int(n.Severity)) + "\x00" + n.Message + "\x00" + n.Details |
| 445 | } | 418 | } | ||
| 446 | 419 | ||||
| 447 | - | // indexOfToastLocked finds a visible toast with the same code and scope. | 420 | + | // sameToastNotice reports whether a and b coalesce into one toast. |
| 448 | - | // Callers must hold noticeMu. | 421 | + | func sameToastNotice(a, b domain.Notification) bool { |
| 449 | - | func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int { | 422 | + | return toastNoticeID(a) == toastNoticeID(b) |
| 450 | - | for i := range rt.noticeToasts { | |||
| 451 | - | if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid { | |||
| 452 | - | return i | |||
| 453 | - | } | |||
| 454 | - | } | |||
| 455 | - | return -1 | |||
| 456 | } | 423 | } | ||
| 457 | 424 | ||||
| 458 | - | // dismissToast removes only the specified visible toast. In particular, a | 425 | + | // dismissToastWithoutRepaint removes only the specified toast. In particular, a |
| 459 | // link-connected event must not clear clipboard or other notice state. | 426 | // link-connected event must not clear clipboard or other notice state. | ||
| 460 | func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool { | 427 | func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool { | ||
| 461 | if ac == nil || ac.overlays == nil { | 428 | if ac == nil || ac.overlays == nil { | ||
| @@ -464,38 +431,36 @@ func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.Noti | |||||
| 464 | rt := ac.overlays | 431 | rt := ac.overlays | ||
| 465 | rt.noticeMu.Lock() | 432 | rt.noticeMu.Lock() | ||
| 466 | defer rt.noticeMu.Unlock() | 433 | defer rt.noticeMu.Unlock() | ||
| 467 | - | i := rt.indexOfToastLocked(code, sid) | 434 | + | if rt.noticeQueue == nil || !rt.noticeQueue.Dismiss(d.clock.Now(), toastNoticeID(domain.Notification{Code: code, SessionID: sid})) { |
| 468 | - | if i < 0 { | |||
| 469 | return false | 435 | return false | ||
| 470 | } | 436 | } | ||
| 471 | - | rt.noticeToasts[i].timer.stop() | 437 | + | d.armToastTimerLocked(ac) |
| 472 | - | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |||
| 473 | - | if len(rt.noticeToasts) == 0 { | |||
| 474 | - | rt.noticeOverflow = 0 | |||
| 475 | - | } | |||
| 476 | return true | 438 | return true | ||
| 477 | } | 439 | } | ||
| 478 | 440 | ||||
| 479 | - | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | 441 | + | // armToastTimerLocked points the client's single toast timer at the queue's |
| 480 | - | // timer goroutine re-acquires it and releases it before repainting, so the | 442 | + | // next expiry. Callers must hold noticeMu; the timer goroutine re-acquires it |
| 481 | - | // callback never runs with noticeMu held. | 443 | + | // and releases it before repainting, so the callback never runs with noticeMu |
| 482 | - | func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | 444 | + | // held. |
| 445 | + | func (d *Daemon) armToastTimerLocked(ac *attachedClient) { | |||
| 483 | rt := ac.overlays | 446 | rt := ac.overlays | ||
| 484 | - | rt.noticeSeq++ | 447 | + | due, ok := rt.noticeQueue.NextDeadline() |
| 485 | - | t.seq = rt.noticeSeq | 448 | + | if ok && due.Equal(rt.noticeDue) && rt.noticeTimer.timer != nil { |
| 486 | - | seq := t.seq | 449 | + | return |
| 487 | - | t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) { | 450 | + | } |
| 451 | + | rt.noticeTimer.stop() | |||
| 452 | + | rt.noticeDue = time.Time{} | |||
| 453 | + | if !ok { | |||
| 454 | + | return | |||
| 455 | + | } | |||
| 456 | + | rt.noticeDue = due | |||
| 457 | + | rt.noticeTimer.retain(d.clock, max(due.Sub(d.clock.Now()), 0), func(fired ports.Timer) { | |||
| 488 | rt.noticeMu.Lock() | 458 | rt.noticeMu.Lock() | ||
| 489 | - | kept := rt.noticeToasts[:0] | 459 | + | rt.noticeQueue.Visible(d.clock.Now()) |
| 490 | - | for _, tt := range rt.noticeToasts { | 460 | + | // A fire that lost the race to a re-arm leaves the newer timer alone. |
| 491 | - | if tt.seq == seq { | 461 | + | if rt.noticeTimer.timer == fired { |
| 492 | - | continue | 462 | + | rt.noticeTimer.timer, rt.noticeTimer.done = nil, nil |
| 493 | - | } | 463 | + | d.armToastTimerLocked(ac) |
| 494 | - | kept = append(kept, tt) | |||
| 495 | - | } | |||
| 496 | - | rt.noticeToasts = kept | |||
| 497 | - | if len(rt.noticeToasts) == 0 { | |||
| 498 | - | rt.noticeOverflow = 0 | |||
| 499 | } | 464 | } | ||
| 500 | rt.noticeMu.Unlock() | 465 | rt.noticeMu.Unlock() | ||
| 501 | d.repaintForNotice(ac) | 466 | d.repaintForNotice(ac) | ||
M internal/usecase/daemon/notify_test.go +42 -34
| @@ -300,11 +300,26 @@ func visibleToasts(ac *attachedClient) ([]domain.Notification, int) { | |||
| 300 | 300 | rt := ac.overlays | |
| 301 | 301 | rt.noticeMu.Lock() | |
| 302 | 302 | defer rt.noticeMu.Unlock() | |
| 303 | - | out := make([]domain.Notification, 0, len(rt.noticeToasts)) | |
| 304 | - | for _, toast := range rt.noticeToasts { | |
| 305 | - | out = append(out, toast.n) | |
| 303 | + | if rt.noticeQueue == nil { | |
| 304 | + | return nil, 0 | |
| 306 | 305 | } | |
| 307 | - | return out, rt.noticeOverflow | |
| 306 | + | var out []domain.Notification | |
| 307 | + | for _, toast := range rt.noticeQueue.Peek() { | |
| 308 | + | out = append(out, toast.Value) | |
| 309 | + | } | |
| 310 | + | return out, rt.noticeQueue.Pending() | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // toastLifetimes lists the visible toasts' lifetimes, newest first. | |
| 314 | + | func toastLifetimes(ac *attachedClient) []time.Duration { | |
| 315 | + | rt := ac.overlays | |
| 316 | + | rt.noticeMu.Lock() | |
| 317 | + | defer rt.noticeMu.Unlock() | |
| 318 | + | var out []time.Duration | |
| 319 | + | for _, toast := range rt.noticeQueue.Peek() { | |
| 320 | + | out = append(out, toast.Duration) | |
| 321 | + | } | |
| 322 | + | return out | |
| 308 | 323 | } | |
| 309 | 324 | ||
| 310 | 325 | // awaitToastCount waits for the toast slice to reach want. The polled condition | |
| @@ -788,7 +803,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 788 | 803 | ||
| 789 | 804 | toasts := awaitToastCount(t, ac, 1) | |
| 790 | 805 | require.Equal(t, 2, toasts[0].Count) | |
| 791 | - | require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl") | |
| 806 | + | require.Equal(t, []time.Duration{4 * time.Second}, toastLifetimes(ac), "coalescing keeps one entry with its severity ttl") | |
| 792 | 807 | _, overflow := visibleToasts(ac) | |
| 793 | 808 | require.Zero(t, overflow) | |
| 794 | 809 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | |
| @@ -806,7 +821,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 806 | 821 | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | |
| 807 | 822 | require.Equal(t, "build started", toasts[1].Message) | |
| 808 | 823 | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | |
| 809 | - | require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl") | |
| 824 | + | require.Equal(t, []time.Duration{6 * time.Second, 4 * time.Second}, toastLifetimes(ac), "each distinct notice keeps its severity ttl") | |
| 810 | 825 | }) | |
| 811 | 826 | ||
| 812 | 827 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | |
| @@ -820,7 +835,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 820 | 835 | require.Equal(t, 1, toasts[1].Count) | |
| 821 | 836 | }) | |
| 822 | 837 | ||
| 823 | - | t.Run("four distinct codes trim to three newest first", func(t *testing.T) { | |
| 838 | + | t.Run("four distinct codes show three newest first and queue one", func(t *testing.T) { | |
| 824 | 839 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 825 | 840 | codes := []domain.NoticeCode{ | |
| 826 | 841 | domain.NoticePaneSpawn, | |
| @@ -833,11 +848,11 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||
| 833 | 848 | } | |
| 834 | 849 | ||
| 835 | 850 | toasts := awaitToastCount(t, ac, maxVisibleToasts) | |
| 836 | - | require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{ | |
| 851 | + | require.Equal(t, []domain.NoticeCode{codes[2], codes[1], codes[0]}, []domain.NoticeCode{ | |
| 837 | 852 | toasts[0].Code, toasts[1].Code, toasts[2].Code, | |
| 838 | - | }, "newest first, oldest evicted") | |
| 839 | - | _, overflow := visibleToasts(ac) | |
| 840 | - | require.Equal(t, 1, overflow) | |
| 853 | + | }, "the first three show newest first; the fourth waits its turn") | |
| 854 | + | _, waiting := visibleToasts(ac) | |
| 855 | + | require.Equal(t, 1, waiting) | |
| 841 | 856 | }) | |
| 842 | 857 | } | |
| 843 | 858 | ||
| @@ -911,32 +926,25 @@ func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) { | |||
| 911 | 926 | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | |
| 912 | 927 | } | |
| 913 | 928 | ||
| 914 | - | // TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an | |
| 915 | - | // expiry timer identifies its toast by entry sequence, not by code and scope. | |
| 916 | - | // Coalescing re-arms the entry under a fresh sequence, so a timer that was | |
| 917 | - | // stopped but had already slipped past its select cannot dismiss the refreshed | |
| 918 | - | // toast that replaced the one it belonged to. | |
| 919 | - | func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) { | |
| 929 | + | // TestToastBurstWaitsAndAdvancesOnExpiry pins the daemon toast queue: a burst | |
| 930 | + | // beyond the visible slots waits as "+N more" and each expiry promotes the | |
| 931 | + | // next waiting notice instead of dropping it. | |
| 932 | + | func TestToastBurstWaitsAndAdvancesOnExpiry(t *testing.T) { | |
| 920 | 933 | clk := newNoticeClock() | |
| 921 | 934 | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 935 | + | codes := []domain.NoticeCode{domain.NoticePaneSpawn, domain.NoticeTabSpawn, domain.NoticeSnapshotWrite, domain.NoticeClipboard, domain.NoticeConfigReload} | |
| 936 | + | for _, c := range codes { | |
| 937 | + | d.notify(sess, domain.NoticeInfo, c, c.String(), nil) | |
| 938 | + | } | |
| 939 | + | awaitToastCount(t, ac, maxVisibleToasts) | |
| 940 | + | _, waiting := visibleToasts(ac) | |
| 941 | + | require.Equal(t, 2, waiting) | |
| 922 | 942 | ||
| 923 | - | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | |
| 924 | - | awaitToastCount(t, ac, 1) | |
| 925 | - | ||
| 926 | - | // Re-stamp the live entry's sequence, exactly as coalescing does, without | |
| 927 | - | // disturbing the already-armed timer. Its code and scope are unchanged. | |
| 928 | - | rt := ac.overlays | |
| 929 | - | rt.noticeMu.Lock() | |
| 930 | - | rt.noticeSeq++ | |
| 931 | - | rt.noticeToasts[0].seq = rt.noticeSeq | |
| 932 | - | rt.noticeMu.Unlock() | |
| 933 | - | ||
| 934 | - | clk.advance(4 * time.Second) | |
| 935 | - | ||
| 936 | - | require.Never(t, func() bool { | |
| 937 | - | ns, _ := visibleToasts(ac) | |
| 938 | - | return len(ns) == 0 | |
| 939 | - | }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed") | |
| 943 | + | clk.advance(noticeTTL(domain.NoticeInfo)) | |
| 944 | + | require.Eventually(t, func() bool { | |
| 945 | + | ns, waiting := visibleToasts(ac) | |
| 946 | + | return len(ns) == 2 && waiting == 0 && ns[0].Code == codes[4] && ns[1].Code == codes[3] | |
| 947 | + | }, 2*time.Second, time.Millisecond, "expiry must promote the waiting notices") | |
| 940 | 948 | } | |
| 941 | 949 | ||
| 942 | 950 | // TestReportErrorClassifiedUserErrorWinsOverBenignCause proves an explicitly | |
| @@ -300,11 +300,26 @@ func visibleToasts(ac *attachedClient) ([]domain.Notification, int) { | |||||
| 300 | rt := ac.overlays | 300 | rt := ac.overlays | ||
| 301 | rt.noticeMu.Lock() | 301 | rt.noticeMu.Lock() | ||
| 302 | defer rt.noticeMu.Unlock() | 302 | defer rt.noticeMu.Unlock() | ||
| 303 | - | out := make([]domain.Notification, 0, len(rt.noticeToasts)) | 303 | + | if rt.noticeQueue == nil { |
| 304 | - | for _, toast := range rt.noticeToasts { | 304 | + | return nil, 0 |
| 305 | - | out = append(out, toast.n) | |||
| 306 | } | 305 | } | ||
| 307 | - | return out, rt.noticeOverflow | 306 | + | var out []domain.Notification |
| 307 | + | for _, toast := range rt.noticeQueue.Peek() { | |||
| 308 | + | out = append(out, toast.Value) | |||
| 309 | + | } | |||
| 310 | + | return out, rt.noticeQueue.Pending() | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // toastLifetimes lists the visible toasts' lifetimes, newest first. | |||
| 314 | + | func toastLifetimes(ac *attachedClient) []time.Duration { | |||
| 315 | + | rt := ac.overlays | |||
| 316 | + | rt.noticeMu.Lock() | |||
| 317 | + | defer rt.noticeMu.Unlock() | |||
| 318 | + | var out []time.Duration | |||
| 319 | + | for _, toast := range rt.noticeQueue.Peek() { | |||
| 320 | + | out = append(out, toast.Duration) | |||
| 321 | + | } | |||
| 322 | + | return out | |||
| 308 | } | 323 | } | ||
| 309 | 324 | ||||
| 310 | // awaitToastCount waits for the toast slice to reach want. The polled condition | 325 | // awaitToastCount waits for the toast slice to reach want. The polled condition | ||
| @@ -788,7 +803,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||||
| 788 | 803 | ||||
| 789 | toasts := awaitToastCount(t, ac, 1) | 804 | toasts := awaitToastCount(t, ac, 1) | ||
| 790 | require.Equal(t, 2, toasts[0].Count) | 805 | require.Equal(t, 2, toasts[0].Count) | ||
| 791 | - | require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl") | 806 | + | require.Equal(t, []time.Duration{4 * time.Second}, toastLifetimes(ac), "coalescing keeps one entry with its severity ttl") |
| 792 | _, overflow := visibleToasts(ac) | 807 | _, overflow := visibleToasts(ac) | ||
| 793 | require.Zero(t, overflow) | 808 | require.Zero(t, overflow) | ||
| 794 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | 809 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | ||
| @@ -806,7 +821,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||||
| 806 | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | 821 | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | ||
| 807 | require.Equal(t, "build started", toasts[1].Message) | 822 | require.Equal(t, "build started", toasts[1].Message) | ||
| 808 | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | 823 | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | ||
| 809 | - | require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl") | 824 | + | require.Equal(t, []time.Duration{6 * time.Second, 4 * time.Second}, toastLifetimes(ac), "each distinct notice keeps its severity ttl") |
| 810 | }) | 825 | }) | ||
| 811 | 826 | ||||
| 812 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | 827 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | ||
| @@ -820,7 +835,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||||
| 820 | require.Equal(t, 1, toasts[1].Count) | 835 | require.Equal(t, 1, toasts[1].Count) | ||
| 821 | }) | 836 | }) | ||
| 822 | 837 | ||||
| 823 | - | t.Run("four distinct codes trim to three newest first", func(t *testing.T) { | 838 | + | t.Run("four distinct codes show three newest first and queue one", func(t *testing.T) { |
| 824 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 839 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 825 | codes := []domain.NoticeCode{ | 840 | codes := []domain.NoticeCode{ | ||
| 826 | domain.NoticePaneSpawn, | 841 | domain.NoticePaneSpawn, | ||
| @@ -833,11 +848,11 @@ func TestShowToastCoalesceAndTrim(t *testing.T) { | |||||
| 833 | } | 848 | } | ||
| 834 | 849 | ||||
| 835 | toasts := awaitToastCount(t, ac, maxVisibleToasts) | 850 | toasts := awaitToastCount(t, ac, maxVisibleToasts) | ||
| 836 | - | require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{ | 851 | + | require.Equal(t, []domain.NoticeCode{codes[2], codes[1], codes[0]}, []domain.NoticeCode{ |
| 837 | toasts[0].Code, toasts[1].Code, toasts[2].Code, | 852 | toasts[0].Code, toasts[1].Code, toasts[2].Code, | ||
| 838 | - | }, "newest first, oldest evicted") | 853 | + | }, "the first three show newest first; the fourth waits its turn") |
| 839 | - | _, overflow := visibleToasts(ac) | 854 | + | _, waiting := visibleToasts(ac) |
| 840 | - | require.Equal(t, 1, overflow) | 855 | + | require.Equal(t, 1, waiting) |
| 841 | }) | 856 | }) | ||
| 842 | } | 857 | } | ||
| 843 | 858 | ||||
| @@ -911,32 +926,25 @@ func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) { | |||||
| 911 | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | 926 | require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed") | ||
| 912 | } | 927 | } | ||
| 913 | 928 | ||||
| 914 | - | // TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an | 929 | + | // TestToastBurstWaitsAndAdvancesOnExpiry pins the daemon toast queue: a burst |
| 915 | - | // expiry timer identifies its toast by entry sequence, not by code and scope. | 930 | + | // beyond the visible slots waits as "+N more" and each expiry promotes the |
| 916 | - | // Coalescing re-arms the entry under a fresh sequence, so a timer that was | 931 | + | // next waiting notice instead of dropping it. |
| 917 | - | // stopped but had already slipped past its select cannot dismiss the refreshed | 932 | + | func TestToastBurstWaitsAndAdvancesOnExpiry(t *testing.T) { |
| 918 | - | // toast that replaced the one it belonged to. | |||
| 919 | - | func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) { | |||
| 920 | clk := newNoticeClock() | 933 | clk := newNoticeClock() | ||
| 921 | d, sess, ac, _ := newNoticeFixture(t, clk) | 934 | d, sess, ac, _ := newNoticeFixture(t, clk) | ||
| 935 | + | codes := []domain.NoticeCode{domain.NoticePaneSpawn, domain.NoticeTabSpawn, domain.NoticeSnapshotWrite, domain.NoticeClipboard, domain.NoticeConfigReload} | |||
| 936 | + | for _, c := range codes { | |||
| 937 | + | d.notify(sess, domain.NoticeInfo, c, c.String(), nil) | |||
| 938 | + | } | |||
| 939 | + | awaitToastCount(t, ac, maxVisibleToasts) | |||
| 940 | + | _, waiting := visibleToasts(ac) | |||
| 941 | + | require.Equal(t, 2, waiting) | |||
| 922 | 942 | ||||
| 923 | - | d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) | 943 | + | clk.advance(noticeTTL(domain.NoticeInfo)) |
| 924 | - | awaitToastCount(t, ac, 1) | 944 | + | require.Eventually(t, func() bool { |
| 925 | - | 945 | + | ns, waiting := visibleToasts(ac) | |
| 926 | - | // Re-stamp the live entry's sequence, exactly as coalescing does, without | 946 | + | return len(ns) == 2 && waiting == 0 && ns[0].Code == codes[4] && ns[1].Code == codes[3] |
| 927 | - | // disturbing the already-armed timer. Its code and scope are unchanged. | 947 | + | }, 2*time.Second, time.Millisecond, "expiry must promote the waiting notices") |
| 928 | - | rt := ac.overlays | |||
| 929 | - | rt.noticeMu.Lock() | |||
| 930 | - | rt.noticeSeq++ | |||
| 931 | - | rt.noticeToasts[0].seq = rt.noticeSeq | |||
| 932 | - | rt.noticeMu.Unlock() | |||
| 933 | - | ||||
| 934 | - | clk.advance(4 * time.Second) | |||
| 935 | - | ||||
| 936 | - | require.Never(t, func() bool { | |||
| 937 | - | ns, _ := visibleToasts(ac) | |||
| 938 | - | return len(ns) == 0 | |||
| 939 | - | }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed") | |||
| 940 | } | 948 | } | ||
| 941 | 949 | ||||
| 942 | // TestReportErrorClassifiedUserErrorWinsOverBenignCause proves an explicitly | 950 | // TestReportErrorClassifiedUserErrorWinsOverBenignCause proves an explicitly | ||
M internal/usecase/daemon/overlay_runtime.go +13 -11
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/usecase/palette" | |
| 12 | 12 | "github.com/bnema/vev/internal/usecase/picker" | |
| 13 | 13 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -112,12 +113,14 @@ type overlayRuntime struct { | |||
| 112 | 113 | // The notification history overlay's fields share this lock rather than | |
| 113 | 114 | // adding one of their own: both concern notice presentation for this | |
| 114 | 115 | // client and neither is ever held across a render call. | |
| 115 | - | noticeMu sync.Mutex | |
| 116 | - | noticeToasts []noticeToast | |
| 117 | - | noticeOverflow int | |
| 118 | - | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | |
| 119 | - | // dismiss the refreshed entry that replaced the one it belonged to. | |
| 120 | - | noticeSeq uint64 | |
| 116 | + | noticeMu sync.Mutex | |
| 117 | + | // noticeQueue is this client's toast stack and its waiting notices. nil | |
| 118 | + | // until the first notice. | |
| 119 | + | noticeQueue *ui.ToastQueue[domain.Notification] | |
| 120 | + | // noticeTimer fires at the queue's next expiry. A stale fire is harmless: | |
| 121 | + | // it only re-evaluates the queue at the current time. | |
| 122 | + | noticeTimer pendingByteTimer | |
| 123 | + | noticeDue time.Time | |
| 121 | 124 | ||
| 122 | 125 | // noticesOverlay is the `notifications` command's history modal. nil when | |
| 123 | 126 | // closed. | |
| @@ -365,13 +368,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 365 | 368 | // noticeMu is innermost and never held across render, so it is taken and | |
| 366 | 369 | // released here rather than tracked like paletteMu/promptMu below. | |
| 367 | 370 | rt.noticeMu.Lock() | |
| 368 | - | if len(rt.noticeToasts) > 0 { | |
| 369 | - | snap.notices = make([]domain.Notification, len(rt.noticeToasts)) | |
| 370 | - | for i, t := range rt.noticeToasts { | |
| 371 | - | snap.notices[i] = t.n | |
| 371 | + | if rt.noticeQueue != nil { | |
| 372 | + | for _, t := range rt.noticeQueue.Peek() { | |
| 373 | + | snap.notices = append(snap.notices, t.Value) | |
| 372 | 374 | } | |
| 375 | + | snap.noticeOverflow = rt.noticeQueue.Pending() | |
| 373 | 376 | } | |
| 374 | - | snap.noticeOverflow = rt.noticeOverflow | |
| 375 | 377 | snap.noticesOverlayActive = rt.noticesOverlay != nil | |
| 376 | 378 | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | |
| 377 | 379 | rt.noticeMu.Unlock() | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/vev/internal/usecase/palette" | 11 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 12 | "github.com/bnema/vev/internal/usecase/picker" | 12 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 13 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 13 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 14 | "github.com/bnema/vev/internal/usecase/visualsearch" | 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -112,12 +113,14 @@ type overlayRuntime struct { | |||||
| 112 | // The notification history overlay's fields share this lock rather than | 113 | // The notification history overlay's fields share this lock rather than | ||
| 113 | // adding one of their own: both concern notice presentation for this | 114 | // adding one of their own: both concern notice presentation for this | ||
| 114 | // client and neither is ever held across a render call. | 115 | // client and neither is ever held across a render call. | ||
| 115 | - | noticeMu sync.Mutex | 116 | + | noticeMu sync.Mutex |
| 116 | - | noticeToasts []noticeToast | 117 | + | // noticeQueue is this client's toast stack and its waiting notices. nil |
| 117 | - | noticeOverflow int | 118 | + | // until the first notice. |
| 118 | - | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | 119 | + | noticeQueue *ui.ToastQueue[domain.Notification] |
| 119 | - | // dismiss the refreshed entry that replaced the one it belonged to. | 120 | + | // noticeTimer fires at the queue's next expiry. A stale fire is harmless: |
| 120 | - | noticeSeq uint64 | 121 | + | // it only re-evaluates the queue at the current time. |
| 122 | + | noticeTimer pendingByteTimer | |||
| 123 | + | noticeDue time.Time | |||
| 121 | 124 | ||||
| 122 | // noticesOverlay is the `notifications` command's history modal. nil when | 125 | // noticesOverlay is the `notifications` command's history modal. nil when | ||
| 123 | // closed. | 126 | // closed. | ||
| @@ -365,13 +368,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 365 | // noticeMu is innermost and never held across render, so it is taken and | 368 | // noticeMu is innermost and never held across render, so it is taken and | ||
| 366 | // released here rather than tracked like paletteMu/promptMu below. | 369 | // released here rather than tracked like paletteMu/promptMu below. | ||
| 367 | rt.noticeMu.Lock() | 370 | rt.noticeMu.Lock() | ||
| 368 | - | if len(rt.noticeToasts) > 0 { | 371 | + | if rt.noticeQueue != nil { |
| 369 | - | snap.notices = make([]domain.Notification, len(rt.noticeToasts)) | 372 | + | for _, t := range rt.noticeQueue.Peek() { |
| 370 | - | for i, t := range rt.noticeToasts { | 373 | + | snap.notices = append(snap.notices, t.Value) |
| 371 | - | snap.notices[i] = t.n | |||
| 372 | } | 374 | } | ||
| 375 | + | snap.noticeOverflow = rt.noticeQueue.Pending() | |||
| 373 | } | 376 | } | ||
| 374 | - | snap.noticeOverflow = rt.noticeOverflow | |||
| 375 | snap.noticesOverlayActive = rt.noticesOverlay != nil | 377 | snap.noticesOverlayActive = rt.noticesOverlay != nil | ||
| 376 | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | 378 | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | ||
| 377 | rt.noticeMu.Unlock() | 379 | rt.noticeMu.Unlock() | ||
M internal/usecase/daemon/palette_inventory_test.go +3 -4
| @@ -229,11 +229,10 @@ func hasInventoryNotice(t *testing.T, ac *attachedClient) bool { | |||
| 229 | 229 | ||
| 230 | 230 | func countInventoryNotices(t *testing.T, ac *attachedClient) int { | |
| 231 | 231 | t.Helper() | |
| 232 | - | ac.overlays.noticeMu.Lock() | |
| 233 | - | defer ac.overlays.noticeMu.Unlock() | |
| 232 | + | toasts, _ := visibleToasts(ac) | |
| 234 | 233 | count := 0 | |
| 235 | - | for _, toast := range ac.overlays.noticeToasts { | |
| 236 | - | if toast.n.Code == domain.NoticeNavigationInventory { | |
| 234 | + | for _, toast := range toasts { | |
| 235 | + | if toast.Code == domain.NoticeNavigationInventory { | |
| 237 | 236 | count++ | |
| 238 | 237 | } | |
| 239 | 238 | } | |
| @@ -229,11 +229,10 @@ func hasInventoryNotice(t *testing.T, ac *attachedClient) bool { | |||||
| 229 | 229 | ||||
| 230 | func countInventoryNotices(t *testing.T, ac *attachedClient) int { | 230 | func countInventoryNotices(t *testing.T, ac *attachedClient) int { | ||
| 231 | t.Helper() | 231 | t.Helper() | ||
| 232 | - | ac.overlays.noticeMu.Lock() | 232 | + | toasts, _ := visibleToasts(ac) |
| 233 | - | defer ac.overlays.noticeMu.Unlock() | |||
| 234 | count := 0 | 233 | count := 0 | ||
| 235 | - | for _, toast := range ac.overlays.noticeToasts { | 234 | + | for _, toast := range toasts { |
| 236 | - | if toast.n.Code == domain.NoticeNavigationInventory { | 235 | + | if toast.Code == domain.NoticeNavigationInventory { |
| 237 | count++ | 236 | count++ | ||
| 238 | } | 237 | } | ||
| 239 | } | 238 | } | ||
M internal/usecase/ui/toast.go +9 -139
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "time" | |
| 5 | - | ||
| 6 | 4 | renderer "github.com/bnema/vev-vt" | |
| 7 | 5 | "github.com/bnema/vev/internal/domain" | |
| 8 | 6 | ) | |
| @@ -15,29 +13,15 @@ const ( | |||
| 15 | 13 | ||
| 16 | 14 | // Toast describes a transient message rendered over a frame. | |
| 17 | 15 | type Toast struct { | |
| 18 | - | ID string | |
| 19 | 16 | Message string | |
| 20 | 17 | // Severity is carried for callers that style by it (client toast | |
| 21 | - | // borders); CompositeToasts does not read it. | |
| 22 | - | Severity domain.NoticeSeverity | |
| 23 | - | Anchor domain.Anchor | |
| 24 | - | Duration time.Duration | |
| 25 | - | DimBackground bool | |
| 26 | - | MinWidth int | |
| 27 | - | MaxWidth int | |
| 28 | - | PaddingX int | |
| 29 | - | PaddingY int | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // ActiveToast is a toast currently tracked by a manager. | |
| 33 | - | type ActiveToast struct { | |
| 34 | - | Toast | |
| 35 | - | ShownAt time.Time | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // ToastManager tracks currently visible toasts. | |
| 39 | - | type ToastManager struct { | |
| 40 | - | active map[string]ActiveToast | |
| 18 | + | // borders); DrawToast does not read it. | |
| 19 | + | Severity domain.NoticeSeverity | |
| 20 | + | Anchor domain.Anchor | |
| 21 | + | MinWidth int | |
| 22 | + | MaxWidth int | |
| 23 | + | PaddingX int | |
| 24 | + | PaddingY int | |
| 41 | 25 | } | |
| 42 | 26 | ||
| 43 | 27 | // ToastStyles contains styles used when drawing a toast. | |
| @@ -46,62 +30,6 @@ type ToastStyles struct { | |||
| 46 | 30 | Box renderer.Style | |
| 47 | 31 | } | |
| 48 | 32 | ||
| 49 | - | // DimStyleFunc transforms an existing cell style for dimmed backgrounds. | |
| 50 | - | type DimStyleFunc func(renderer.Style) renderer.Style | |
| 51 | - | ||
| 52 | - | // NewToastManager creates an empty toast manager. | |
| 53 | - | func NewToastManager() *ToastManager { | |
| 54 | - | return &ToastManager{active: make(map[string]ActiveToast)} | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // Show records toast as active at now. Empty IDs are replaced with a stable key. | |
| 58 | - | func (m *ToastManager) Show(now time.Time, toast Toast) { | |
| 59 | - | if m.active == nil { | |
| 60 | - | m.active = make(map[string]ActiveToast) | |
| 61 | - | } | |
| 62 | - | id := toast.ID | |
| 63 | - | if id == "" { | |
| 64 | - | id = toastAnchorID(toast.Anchor) | |
| 65 | - | toast.ID = id | |
| 66 | - | } | |
| 67 | - | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func toastAnchorID(anchor domain.Anchor) string { | |
| 71 | - | return "anchor:" + anchor.String() | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // Dismiss removes a toast by ID. | |
| 75 | - | func (m *ToastManager) Dismiss(id string) { | |
| 76 | - | delete(m.active, id) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Clear removes all toasts. | |
| 80 | - | func (m *ToastManager) Clear() { | |
| 81 | - | m.active = make(map[string]ActiveToast) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // Active returns non-expired toasts at now and prunes expired entries. | |
| 85 | - | func (m *ToastManager) Active(now time.Time) []ActiveToast { | |
| 86 | - | if m.active == nil { | |
| 87 | - | return nil | |
| 88 | - | } | |
| 89 | - | active := make([]ActiveToast, 0, len(m.active)) | |
| 90 | - | for id, toast := range m.active { | |
| 91 | - | if toast.Duration > 0 && !now.Before(toast.ShownAt.Add(toast.Duration)) { | |
| 92 | - | delete(m.active, id) | |
| 93 | - | continue | |
| 94 | - | } | |
| 95 | - | active = append(active, toast) | |
| 96 | - | } | |
| 97 | - | return active | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // HasActive reports whether any toast is active at now. | |
| 101 | - | func (m *ToastManager) HasActive(now time.Time) bool { | |
| 102 | - | return len(m.Active(now)) > 0 | |
| 103 | - | } | |
| 104 | - | ||
| 105 | 33 | // ToastBounds returns the toast rectangle positioned within base and clamped to base. | |
| 106 | 34 | func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |
| 107 | 35 | if base.Cols <= 0 || base.Rows <= 0 { | |
| @@ -135,66 +63,8 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |||
| 135 | 63 | }) | |
| 136 | 64 | } | |
| 137 | 65 | ||
| 138 | - | // CompositeToasts draws the latest toast per anchor over frame. | |
| 139 | - | func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastStyles, dim DimStyleFunc) { | |
| 140 | - | visible := latestToastsByAnchor(toasts) | |
| 141 | - | if len(visible) == 0 { | |
| 142 | - | return | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | if dim != nil { | |
| 146 | - | shouldDim := false | |
| 147 | - | for _, toast := range visible { | |
| 148 | - | if toast.DimBackground { | |
| 149 | - | shouldDim = true | |
| 150 | - | break | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | if shouldDim { | |
| 154 | - | for y := 0; y < frame.Height; y++ { | |
| 155 | - | for x := 0; x < frame.Width; x++ { | |
| 156 | - | cell := frame.At(x, y) | |
| 157 | - | cell.Style = dim(cell.Style) | |
| 158 | - | frame.Set(x, y, cell) | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | for _, toast := range visible { | |
| 165 | - | drawToast(frame, toast.Toast, styles) | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | |
| 170 | - | byAnchor := make(map[domain.Anchor]ActiveToast) | |
| 171 | - | for _, toast := range toasts { | |
| 172 | - | current, ok := byAnchor[toast.Anchor] | |
| 173 | - | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | |
| 174 | - | byAnchor[toast.Anchor] = toast | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | anchors := []domain.Anchor{ | |
| 178 | - | domain.AnchorTopLeft, | |
| 179 | - | domain.AnchorTopRight, | |
| 180 | - | domain.AnchorBottomLeft, | |
| 181 | - | domain.AnchorBottomRight, | |
| 182 | - | domain.AnchorCenter, | |
| 183 | - | domain.AnchorTop, | |
| 184 | - | domain.AnchorLeft, | |
| 185 | - | domain.AnchorRight, | |
| 186 | - | domain.AnchorBottom, | |
| 187 | - | } | |
| 188 | - | visible := make([]ActiveToast, 0, len(byAnchor)) | |
| 189 | - | for _, anchor := range anchors { | |
| 190 | - | if toast, ok := byAnchor[anchor]; ok { | |
| 191 | - | visible = append(visible, toast) | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | return visible | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { | |
| 66 | + | // DrawToast draws one bordered toast at its anchor within frame. | |
| 67 | + | func DrawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { | |
| 198 | 68 | bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast) | |
| 199 | 69 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 200 | 70 | return | |
| @@ -1,8 +1,6 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "time" | |||
| 5 | - | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 4 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 8 | ) | 6 | ) | ||
| @@ -15,29 +13,15 @@ const ( | |||||
| 15 | 13 | ||||
| 16 | // Toast describes a transient message rendered over a frame. | 14 | // Toast describes a transient message rendered over a frame. | ||
| 17 | type Toast struct { | 15 | type Toast struct { | ||
| 18 | - | ID string | |||
| 19 | Message string | 16 | Message string | ||
| 20 | // Severity is carried for callers that style by it (client toast | 17 | // Severity is carried for callers that style by it (client toast | ||
| 21 | - | // borders); CompositeToasts does not read it. | 18 | + | // borders); DrawToast does not read it. |
| 22 | - | Severity domain.NoticeSeverity | 19 | + | Severity domain.NoticeSeverity |
| 23 | - | Anchor domain.Anchor | 20 | + | Anchor domain.Anchor |
| 24 | - | Duration time.Duration | 21 | + | MinWidth int |
| 25 | - | DimBackground bool | 22 | + | MaxWidth int |
| 26 | - | MinWidth int | 23 | + | PaddingX int |
| 27 | - | MaxWidth int | 24 | + | PaddingY int |
| 28 | - | PaddingX int | |||
| 29 | - | PaddingY int | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | // ActiveToast is a toast currently tracked by a manager. | |||
| 33 | - | type ActiveToast struct { | |||
| 34 | - | Toast | |||
| 35 | - | ShownAt time.Time | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // ToastManager tracks currently visible toasts. | |||
| 39 | - | type ToastManager struct { | |||
| 40 | - | active map[string]ActiveToast | |||
| 41 | } | 25 | } | ||
| 42 | 26 | ||||
| 43 | // ToastStyles contains styles used when drawing a toast. | 27 | // ToastStyles contains styles used when drawing a toast. | ||
| @@ -46,62 +30,6 @@ type ToastStyles struct { | |||||
| 46 | Box renderer.Style | 30 | Box renderer.Style | ||
| 47 | } | 31 | } | ||
| 48 | 32 | ||||
| 49 | - | // DimStyleFunc transforms an existing cell style for dimmed backgrounds. | |||
| 50 | - | type DimStyleFunc func(renderer.Style) renderer.Style | |||
| 51 | - | ||||
| 52 | - | // NewToastManager creates an empty toast manager. | |||
| 53 | - | func NewToastManager() *ToastManager { | |||
| 54 | - | return &ToastManager{active: make(map[string]ActiveToast)} | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // Show records toast as active at now. Empty IDs are replaced with a stable key. | |||
| 58 | - | func (m *ToastManager) Show(now time.Time, toast Toast) { | |||
| 59 | - | if m.active == nil { | |||
| 60 | - | m.active = make(map[string]ActiveToast) | |||
| 61 | - | } | |||
| 62 | - | id := toast.ID | |||
| 63 | - | if id == "" { | |||
| 64 | - | id = toastAnchorID(toast.Anchor) | |||
| 65 | - | toast.ID = id | |||
| 66 | - | } | |||
| 67 | - | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func toastAnchorID(anchor domain.Anchor) string { | |||
| 71 | - | return "anchor:" + anchor.String() | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // Dismiss removes a toast by ID. | |||
| 75 | - | func (m *ToastManager) Dismiss(id string) { | |||
| 76 | - | delete(m.active, id) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // Clear removes all toasts. | |||
| 80 | - | func (m *ToastManager) Clear() { | |||
| 81 | - | m.active = make(map[string]ActiveToast) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // Active returns non-expired toasts at now and prunes expired entries. | |||
| 85 | - | func (m *ToastManager) Active(now time.Time) []ActiveToast { | |||
| 86 | - | if m.active == nil { | |||
| 87 | - | return nil | |||
| 88 | - | } | |||
| 89 | - | active := make([]ActiveToast, 0, len(m.active)) | |||
| 90 | - | for id, toast := range m.active { | |||
| 91 | - | if toast.Duration > 0 && !now.Before(toast.ShownAt.Add(toast.Duration)) { | |||
| 92 | - | delete(m.active, id) | |||
| 93 | - | continue | |||
| 94 | - | } | |||
| 95 | - | active = append(active, toast) | |||
| 96 | - | } | |||
| 97 | - | return active | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // HasActive reports whether any toast is active at now. | |||
| 101 | - | func (m *ToastManager) HasActive(now time.Time) bool { | |||
| 102 | - | return len(m.Active(now)) > 0 | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | // ToastBounds returns the toast rectangle positioned within base and clamped to base. | 33 | // ToastBounds returns the toast rectangle positioned within base and clamped to base. | ||
| 106 | func ToastBounds(base domain.Size, toast Toast) domain.Rect { | 34 | func ToastBounds(base domain.Size, toast Toast) domain.Rect { | ||
| 107 | if base.Cols <= 0 || base.Rows <= 0 { | 35 | if base.Cols <= 0 || base.Rows <= 0 { | ||
| @@ -135,66 +63,8 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |||||
| 135 | }) | 63 | }) | ||
| 136 | } | 64 | } | ||
| 137 | 65 | ||||
| 138 | - | // CompositeToasts draws the latest toast per anchor over frame. | 66 | + | // DrawToast draws one bordered toast at its anchor within frame. |
| 139 | - | func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastStyles, dim DimStyleFunc) { | 67 | + | func DrawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { |
| 140 | - | visible := latestToastsByAnchor(toasts) | |||
| 141 | - | if len(visible) == 0 { | |||
| 142 | - | return | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | if dim != nil { | |||
| 146 | - | shouldDim := false | |||
| 147 | - | for _, toast := range visible { | |||
| 148 | - | if toast.DimBackground { | |||
| 149 | - | shouldDim = true | |||
| 150 | - | break | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | if shouldDim { | |||
| 154 | - | for y := 0; y < frame.Height; y++ { | |||
| 155 | - | for x := 0; x < frame.Width; x++ { | |||
| 156 | - | cell := frame.At(x, y) | |||
| 157 | - | cell.Style = dim(cell.Style) | |||
| 158 | - | frame.Set(x, y, cell) | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | } | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | for _, toast := range visible { | |||
| 165 | - | drawToast(frame, toast.Toast, styles) | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | |||
| 170 | - | byAnchor := make(map[domain.Anchor]ActiveToast) | |||
| 171 | - | for _, toast := range toasts { | |||
| 172 | - | current, ok := byAnchor[toast.Anchor] | |||
| 173 | - | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | |||
| 174 | - | byAnchor[toast.Anchor] = toast | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | anchors := []domain.Anchor{ | |||
| 178 | - | domain.AnchorTopLeft, | |||
| 179 | - | domain.AnchorTopRight, | |||
| 180 | - | domain.AnchorBottomLeft, | |||
| 181 | - | domain.AnchorBottomRight, | |||
| 182 | - | domain.AnchorCenter, | |||
| 183 | - | domain.AnchorTop, | |||
| 184 | - | domain.AnchorLeft, | |||
| 185 | - | domain.AnchorRight, | |||
| 186 | - | domain.AnchorBottom, | |||
| 187 | - | } | |||
| 188 | - | visible := make([]ActiveToast, 0, len(byAnchor)) | |||
| 189 | - | for _, anchor := range anchors { | |||
| 190 | - | if toast, ok := byAnchor[anchor]; ok { | |||
| 191 | - | visible = append(visible, toast) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | return visible | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | - | func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { | |||
| 198 | bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast) | 68 | bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast) | ||
| 199 | if bounds.Width <= 0 || bounds.Height <= 0 { | 69 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 200 | return | 70 | return | ||
A internal/usecase/ui/toast_queue.go +240 -0
| @@ -0,0 +1,240 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ToastQueueOptions bound one ToastQueue. Zero values select the defaults. | |
| 10 | + | type ToastQueueOptions struct { | |
| 11 | + | // MaxVisible is how many toasts show at once (default 3). | |
| 12 | + | MaxVisible int | |
| 13 | + | // MaxPending is how many toasts wait for a free slot (default 32). When | |
| 14 | + | // full, the oldest waiting toast is dropped. | |
| 15 | + | MaxPending int | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | defaultQueueVisible = 3 | |
| 20 | + | defaultQueuePending = 32 | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // QueuedToast is one entry of a ToastQueue. Value is the caller's payload. | |
| 24 | + | type QueuedToast[T any] struct { | |
| 25 | + | // ID is the coalescing identity: pushing an ID that is visible or waiting | |
| 26 | + | // replaces that entry. An empty ID never matches. | |
| 27 | + | ID string | |
| 28 | + | Value T | |
| 29 | + | // Duration is how long the entry stays visible; zero never expires. | |
| 30 | + | Duration time.Duration | |
| 31 | + | // ShownAt is when the entry became visible. Push sets it. | |
| 32 | + | ShownAt time.Time | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ToastQueue shows a bounded number of toasts and queues the rest, so a burst | |
| 36 | + | // is displayed one slot at a time instead of being dropped or overdrawn. | |
| 37 | + | // Time comes only | |
| 38 | + | // from the caller, so the queue is deterministic; callers serialize access. | |
| 39 | + | type ToastQueue[T any] struct { | |
| 40 | + | opts ToastQueueOptions | |
| 41 | + | visible []QueuedToast[T] // newest first | |
| 42 | + | pending []QueuedToast[T] // oldest first | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // NewToastQueue returns an empty queue with opts, defaults filled in. | |
| 46 | + | func NewToastQueue[T any](opts ToastQueueOptions) *ToastQueue[T] { | |
| 47 | + | if opts.MaxVisible <= 0 { | |
| 48 | + | opts.MaxVisible = defaultQueueVisible | |
| 49 | + | } | |
| 50 | + | if opts.MaxPending <= 0 { | |
| 51 | + | opts.MaxPending = defaultQueuePending | |
| 52 | + | } | |
| 53 | + | return &ToastQueue[T]{opts: opts} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Push shows toast at now when a slot is free, otherwise queues it. A visible | |
| 57 | + | // entry with the same ID is replaced, moved to the front, and restarts its | |
| 58 | + | // lifetime; a waiting one is replaced where it waits. | |
| 59 | + | func (q *ToastQueue[T]) Push(now time.Time, toast QueuedToast[T]) { | |
| 60 | + | q.expire(now) | |
| 61 | + | toast.ShownAt = now | |
| 62 | + | if i := q.indexVisible(toast.ID); i >= 0 { | |
| 63 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |
| 64 | + | q.visible = append([]QueuedToast[T]{toast}, q.visible...) | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | if i := q.indexPending(toast.ID); i >= 0 { | |
| 68 | + | q.pending[i] = toast | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | if len(q.visible) < q.opts.MaxVisible { | |
| 72 | + | q.visible = append([]QueuedToast[T]{toast}, q.visible...) | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | if len(q.pending) >= q.opts.MaxPending { | |
| 76 | + | q.pending = q.pending[1:] | |
| 77 | + | } | |
| 78 | + | q.pending = append(q.pending, toast) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Get returns the visible or waiting entry with id. | |
| 82 | + | func (q *ToastQueue[T]) Get(id string) (QueuedToast[T], bool) { | |
| 83 | + | if i := q.indexVisible(id); i >= 0 { | |
| 84 | + | return q.visible[i], true | |
| 85 | + | } | |
| 86 | + | if i := q.indexPending(id); i >= 0 { | |
| 87 | + | return q.pending[i], true | |
| 88 | + | } | |
| 89 | + | return QueuedToast[T]{}, false | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Visible expires entries due at now, promotes waiting ones into the freed | |
| 93 | + | // slots, and returns the displayed entries newest first. | |
| 94 | + | func (q *ToastQueue[T]) Visible(now time.Time) []QueuedToast[T] { | |
| 95 | + | q.expire(now) | |
| 96 | + | return append([]QueuedToast[T](nil), q.visible...) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Peek returns the displayed entries newest first without expiring any, for | |
| 100 | + | // readers that must not change the queue, such as a render snapshot. | |
| 101 | + | func (q *ToastQueue[T]) Peek() []QueuedToast[T] { | |
| 102 | + | return append([]QueuedToast[T](nil), q.visible...) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Pending reports how many entries wait for a slot. | |
| 106 | + | func (q *ToastQueue[T]) Pending() int { return len(q.pending) } | |
| 107 | + | ||
| 108 | + | // NextDeadline is the earliest instant a visible entry expires. | |
| 109 | + | func (q *ToastQueue[T]) NextDeadline() (time.Time, bool) { | |
| 110 | + | var next time.Time | |
| 111 | + | found := false | |
| 112 | + | for _, t := range q.visible { | |
| 113 | + | if t.Duration <= 0 { | |
| 114 | + | continue | |
| 115 | + | } | |
| 116 | + | due := t.ShownAt.Add(t.Duration) | |
| 117 | + | if !found || due.Before(next) { | |
| 118 | + | next, found = due, true | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return next, found | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Dismiss removes the visible or waiting entry with id and fills a | |
| 125 | + | // freed slot at now. It reports whether an entry was removed. | |
| 126 | + | func (q *ToastQueue[T]) Dismiss(now time.Time, id string) bool { | |
| 127 | + | removed := false | |
| 128 | + | if i := q.indexVisible(id); i >= 0 { | |
| 129 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |
| 130 | + | removed = true | |
| 131 | + | } else if i := q.indexPending(id); i >= 0 { | |
| 132 | + | q.pending = append(q.pending[:i], q.pending[i+1:]...) | |
| 133 | + | removed = true | |
| 134 | + | } | |
| 135 | + | q.expire(now) | |
| 136 | + | q.promote(now) | |
| 137 | + | return removed | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (q *ToastQueue[T]) indexVisible(id string) int { | |
| 141 | + | if id == "" { | |
| 142 | + | return -1 | |
| 143 | + | } | |
| 144 | + | for i := range q.visible { | |
| 145 | + | if q.visible[i].ID == id { | |
| 146 | + | return i | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | return -1 | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (q *ToastQueue[T]) indexPending(id string) int { | |
| 153 | + | if id == "" { | |
| 154 | + | return -1 | |
| 155 | + | } | |
| 156 | + | for i := range q.pending { | |
| 157 | + | if q.pending[i].ID == id { | |
| 158 | + | return i | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return -1 | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // expire retires due entries oldest first and fills each freed slot at the | |
| 165 | + | // instant it was freed, so a burst advances at a steady pace even when the | |
| 166 | + | // caller polls late. | |
| 167 | + | func (q *ToastQueue[T]) expire(now time.Time) { | |
| 168 | + | for { | |
| 169 | + | i, due := q.firstDue(now) | |
| 170 | + | if i < 0 { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |
| 174 | + | q.promote(due) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // promote fills free slots with waiting entries, shown from at. | |
| 179 | + | func (q *ToastQueue[T]) promote(at time.Time) { | |
| 180 | + | for len(q.visible) < q.opts.MaxVisible && len(q.pending) > 0 { | |
| 181 | + | next := q.pending[0] | |
| 182 | + | q.pending = q.pending[1:] | |
| 183 | + | next.ShownAt = at | |
| 184 | + | q.visible = append([]QueuedToast[T]{next}, q.visible...) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (q *ToastQueue[T]) firstDue(now time.Time) (int, time.Time) { | |
| 189 | + | idx := -1 | |
| 190 | + | var first time.Time | |
| 191 | + | for i, t := range q.visible { | |
| 192 | + | if t.Duration <= 0 { | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | due := t.ShownAt.Add(t.Duration) | |
| 196 | + | if now.Before(due) { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | // visible is newest first, so a later index wins a tie: the oldest | |
| 200 | + | // entry leaves first. | |
| 201 | + | if idx < 0 || !due.After(first) { | |
| 202 | + | idx, first = i, due | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return idx, first | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // ToastStackBounds lays toasts (newest first) out as a column at their shared | |
| 209 | + | // anchor: the newest sits at the anchor and older ones step away from it, | |
| 210 | + | // downward for top and middle anchors and upward for bottom anchors. Boxes that | |
| 211 | + | // would leave base are omitted, so the result may be shorter than toasts. | |
| 212 | + | func ToastStackBounds(base domain.Size, toasts []Toast) []domain.Rect { | |
| 213 | + | rects := make([]domain.Rect, 0, len(toasts)) | |
| 214 | + | offset := 0 | |
| 215 | + | for _, toast := range toasts { | |
| 216 | + | r := ToastBounds(base, toast) | |
| 217 | + | if r.Width <= 0 || r.Height <= 0 { | |
| 218 | + | break | |
| 219 | + | } | |
| 220 | + | if growsUp(toast.Anchor) { | |
| 221 | + | r.Y -= offset | |
| 222 | + | } else { | |
| 223 | + | r.Y += offset | |
| 224 | + | } | |
| 225 | + | if r.Y < 0 || r.Y+r.Height > base.Rows { | |
| 226 | + | break | |
| 227 | + | } | |
| 228 | + | rects = append(rects, r) | |
| 229 | + | offset += r.Height | |
| 230 | + | } | |
| 231 | + | return rects | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func growsUp(anchor domain.Anchor) bool { | |
| 235 | + | switch anchor { | |
| 236 | + | case domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight: | |
| 237 | + | return true | |
| 238 | + | } | |
| 239 | + | return false | |
| 240 | + | } | |
| @@ -0,0 +1,240 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ToastQueueOptions bound one ToastQueue. Zero values select the defaults. | |||
| 10 | + | type ToastQueueOptions struct { | |||
| 11 | + | // MaxVisible is how many toasts show at once (default 3). | |||
| 12 | + | MaxVisible int | |||
| 13 | + | // MaxPending is how many toasts wait for a free slot (default 32). When | |||
| 14 | + | // full, the oldest waiting toast is dropped. | |||
| 15 | + | MaxPending int | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | defaultQueueVisible = 3 | |||
| 20 | + | defaultQueuePending = 32 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // QueuedToast is one entry of a ToastQueue. Value is the caller's payload. | |||
| 24 | + | type QueuedToast[T any] struct { | |||
| 25 | + | // ID is the coalescing identity: pushing an ID that is visible or waiting | |||
| 26 | + | // replaces that entry. An empty ID never matches. | |||
| 27 | + | ID string | |||
| 28 | + | Value T | |||
| 29 | + | // Duration is how long the entry stays visible; zero never expires. | |||
| 30 | + | Duration time.Duration | |||
| 31 | + | // ShownAt is when the entry became visible. Push sets it. | |||
| 32 | + | ShownAt time.Time | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ToastQueue shows a bounded number of toasts and queues the rest, so a burst | |||
| 36 | + | // is displayed one slot at a time instead of being dropped or overdrawn. | |||
| 37 | + | // Time comes only | |||
| 38 | + | // from the caller, so the queue is deterministic; callers serialize access. | |||
| 39 | + | type ToastQueue[T any] struct { | |||
| 40 | + | opts ToastQueueOptions | |||
| 41 | + | visible []QueuedToast[T] // newest first | |||
| 42 | + | pending []QueuedToast[T] // oldest first | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // NewToastQueue returns an empty queue with opts, defaults filled in. | |||
| 46 | + | func NewToastQueue[T any](opts ToastQueueOptions) *ToastQueue[T] { | |||
| 47 | + | if opts.MaxVisible <= 0 { | |||
| 48 | + | opts.MaxVisible = defaultQueueVisible | |||
| 49 | + | } | |||
| 50 | + | if opts.MaxPending <= 0 { | |||
| 51 | + | opts.MaxPending = defaultQueuePending | |||
| 52 | + | } | |||
| 53 | + | return &ToastQueue[T]{opts: opts} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Push shows toast at now when a slot is free, otherwise queues it. A visible | |||
| 57 | + | // entry with the same ID is replaced, moved to the front, and restarts its | |||
| 58 | + | // lifetime; a waiting one is replaced where it waits. | |||
| 59 | + | func (q *ToastQueue[T]) Push(now time.Time, toast QueuedToast[T]) { | |||
| 60 | + | q.expire(now) | |||
| 61 | + | toast.ShownAt = now | |||
| 62 | + | if i := q.indexVisible(toast.ID); i >= 0 { | |||
| 63 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |||
| 64 | + | q.visible = append([]QueuedToast[T]{toast}, q.visible...) | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | if i := q.indexPending(toast.ID); i >= 0 { | |||
| 68 | + | q.pending[i] = toast | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | if len(q.visible) < q.opts.MaxVisible { | |||
| 72 | + | q.visible = append([]QueuedToast[T]{toast}, q.visible...) | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | if len(q.pending) >= q.opts.MaxPending { | |||
| 76 | + | q.pending = q.pending[1:] | |||
| 77 | + | } | |||
| 78 | + | q.pending = append(q.pending, toast) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Get returns the visible or waiting entry with id. | |||
| 82 | + | func (q *ToastQueue[T]) Get(id string) (QueuedToast[T], bool) { | |||
| 83 | + | if i := q.indexVisible(id); i >= 0 { | |||
| 84 | + | return q.visible[i], true | |||
| 85 | + | } | |||
| 86 | + | if i := q.indexPending(id); i >= 0 { | |||
| 87 | + | return q.pending[i], true | |||
| 88 | + | } | |||
| 89 | + | return QueuedToast[T]{}, false | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Visible expires entries due at now, promotes waiting ones into the freed | |||
| 93 | + | // slots, and returns the displayed entries newest first. | |||
| 94 | + | func (q *ToastQueue[T]) Visible(now time.Time) []QueuedToast[T] { | |||
| 95 | + | q.expire(now) | |||
| 96 | + | return append([]QueuedToast[T](nil), q.visible...) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Peek returns the displayed entries newest first without expiring any, for | |||
| 100 | + | // readers that must not change the queue, such as a render snapshot. | |||
| 101 | + | func (q *ToastQueue[T]) Peek() []QueuedToast[T] { | |||
| 102 | + | return append([]QueuedToast[T](nil), q.visible...) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Pending reports how many entries wait for a slot. | |||
| 106 | + | func (q *ToastQueue[T]) Pending() int { return len(q.pending) } | |||
| 107 | + | ||||
| 108 | + | // NextDeadline is the earliest instant a visible entry expires. | |||
| 109 | + | func (q *ToastQueue[T]) NextDeadline() (time.Time, bool) { | |||
| 110 | + | var next time.Time | |||
| 111 | + | found := false | |||
| 112 | + | for _, t := range q.visible { | |||
| 113 | + | if t.Duration <= 0 { | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | due := t.ShownAt.Add(t.Duration) | |||
| 117 | + | if !found || due.Before(next) { | |||
| 118 | + | next, found = due, true | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return next, found | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Dismiss removes the visible or waiting entry with id and fills a | |||
| 125 | + | // freed slot at now. It reports whether an entry was removed. | |||
| 126 | + | func (q *ToastQueue[T]) Dismiss(now time.Time, id string) bool { | |||
| 127 | + | removed := false | |||
| 128 | + | if i := q.indexVisible(id); i >= 0 { | |||
| 129 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |||
| 130 | + | removed = true | |||
| 131 | + | } else if i := q.indexPending(id); i >= 0 { | |||
| 132 | + | q.pending = append(q.pending[:i], q.pending[i+1:]...) | |||
| 133 | + | removed = true | |||
| 134 | + | } | |||
| 135 | + | q.expire(now) | |||
| 136 | + | q.promote(now) | |||
| 137 | + | return removed | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (q *ToastQueue[T]) indexVisible(id string) int { | |||
| 141 | + | if id == "" { | |||
| 142 | + | return -1 | |||
| 143 | + | } | |||
| 144 | + | for i := range q.visible { | |||
| 145 | + | if q.visible[i].ID == id { | |||
| 146 | + | return i | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | return -1 | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (q *ToastQueue[T]) indexPending(id string) int { | |||
| 153 | + | if id == "" { | |||
| 154 | + | return -1 | |||
| 155 | + | } | |||
| 156 | + | for i := range q.pending { | |||
| 157 | + | if q.pending[i].ID == id { | |||
| 158 | + | return i | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return -1 | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // expire retires due entries oldest first and fills each freed slot at the | |||
| 165 | + | // instant it was freed, so a burst advances at a steady pace even when the | |||
| 166 | + | // caller polls late. | |||
| 167 | + | func (q *ToastQueue[T]) expire(now time.Time) { | |||
| 168 | + | for { | |||
| 169 | + | i, due := q.firstDue(now) | |||
| 170 | + | if i < 0 { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | q.visible = append(q.visible[:i], q.visible[i+1:]...) | |||
| 174 | + | q.promote(due) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // promote fills free slots with waiting entries, shown from at. | |||
| 179 | + | func (q *ToastQueue[T]) promote(at time.Time) { | |||
| 180 | + | for len(q.visible) < q.opts.MaxVisible && len(q.pending) > 0 { | |||
| 181 | + | next := q.pending[0] | |||
| 182 | + | q.pending = q.pending[1:] | |||
| 183 | + | next.ShownAt = at | |||
| 184 | + | q.visible = append([]QueuedToast[T]{next}, q.visible...) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (q *ToastQueue[T]) firstDue(now time.Time) (int, time.Time) { | |||
| 189 | + | idx := -1 | |||
| 190 | + | var first time.Time | |||
| 191 | + | for i, t := range q.visible { | |||
| 192 | + | if t.Duration <= 0 { | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | due := t.ShownAt.Add(t.Duration) | |||
| 196 | + | if now.Before(due) { | |||
| 197 | + | continue | |||
| 198 | + | } | |||
| 199 | + | // visible is newest first, so a later index wins a tie: the oldest | |||
| 200 | + | // entry leaves first. | |||
| 201 | + | if idx < 0 || !due.After(first) { | |||
| 202 | + | idx, first = i, due | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return idx, first | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // ToastStackBounds lays toasts (newest first) out as a column at their shared | |||
| 209 | + | // anchor: the newest sits at the anchor and older ones step away from it, | |||
| 210 | + | // downward for top and middle anchors and upward for bottom anchors. Boxes that | |||
| 211 | + | // would leave base are omitted, so the result may be shorter than toasts. | |||
| 212 | + | func ToastStackBounds(base domain.Size, toasts []Toast) []domain.Rect { | |||
| 213 | + | rects := make([]domain.Rect, 0, len(toasts)) | |||
| 214 | + | offset := 0 | |||
| 215 | + | for _, toast := range toasts { | |||
| 216 | + | r := ToastBounds(base, toast) | |||
| 217 | + | if r.Width <= 0 || r.Height <= 0 { | |||
| 218 | + | break | |||
| 219 | + | } | |||
| 220 | + | if growsUp(toast.Anchor) { | |||
| 221 | + | r.Y -= offset | |||
| 222 | + | } else { | |||
| 223 | + | r.Y += offset | |||
| 224 | + | } | |||
| 225 | + | if r.Y < 0 || r.Y+r.Height > base.Rows { | |||
| 226 | + | break | |||
| 227 | + | } | |||
| 228 | + | rects = append(rects, r) | |||
| 229 | + | offset += r.Height | |||
| 230 | + | } | |||
| 231 | + | return rects | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func growsUp(anchor domain.Anchor) bool { | |||
| 235 | + | switch anchor { | |||
| 236 | + | case domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight: | |||
| 237 | + | return true | |||
| 238 | + | } | |||
| 239 | + | return false | |||
| 240 | + | } | |||
A internal/usecase/ui/toast_queue_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestToastQueue(t *testing.T) { | |
| 12 | + | start := time.Unix(100, 0) | |
| 13 | + | life := 4 * time.Second | |
| 14 | + | toast := func(id string) QueuedToast[string] { return QueuedToast[string]{ID: id, Value: id, Duration: life} } | |
| 15 | + | ids := func(ts []QueuedToast[string]) []string { | |
| 16 | + | out := make([]string, len(ts)) | |
| 17 | + | for i, t := range ts { | |
| 18 | + | out[i] = t.ID | |
| 19 | + | } | |
| 20 | + | return out | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | opts ToastQueueOptions | |
| 26 | + | push []QueuedToast[string] | |
| 27 | + | at time.Duration | |
| 28 | + | wantVisible []string | |
| 29 | + | wantPending int | |
| 30 | + | }{ | |
| 31 | + | {name: "fills slots newest first", opts: ToastQueueOptions{MaxVisible: 3}, push: []QueuedToast[string]{toast("a"), toast("b")}, wantVisible: []string{"b", "a"}}, | |
| 32 | + | {name: "overflow waits", opts: ToastQueueOptions{MaxVisible: 2}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, wantVisible: []string{"b", "a"}, wantPending: 1}, | |
| 33 | + | {name: "expiry promotes waiting toasts", opts: ToastQueueOptions{MaxVisible: 2}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, at: life, wantVisible: []string{"c"}}, | |
| 34 | + | {name: "late poll paces the burst", opts: ToastQueueOptions{MaxVisible: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, at: 2 * life, wantVisible: []string{"c"}}, | |
| 35 | + | {name: "same id replaces in place", opts: ToastQueueOptions{MaxVisible: 3}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("a")}, wantVisible: []string{"a", "b"}}, | |
| 36 | + | {name: "same id replaces waiting", opts: ToastQueueOptions{MaxVisible: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("b")}, wantVisible: []string{"a"}, wantPending: 1}, | |
| 37 | + | {name: "full queue drops oldest waiting", opts: ToastQueueOptions{MaxVisible: 1, MaxPending: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, wantVisible: []string{"a"}, wantPending: 1}, | |
| 38 | + | {name: "persistent toast never expires", opts: ToastQueueOptions{}, push: []QueuedToast[string]{{ID: "p"}}, at: time.Hour, wantVisible: []string{"p"}}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | q := NewToastQueue[string](tt.opts) | |
| 43 | + | for _, p := range tt.push { | |
| 44 | + | q.Push(start, p) | |
| 45 | + | } | |
| 46 | + | require.Equal(t, tt.wantVisible, nilIfEmpty(ids(q.Visible(start.Add(tt.at))))) | |
| 47 | + | require.Equal(t, tt.wantPending, q.Pending()) | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestToastQueueDeadlineAndDismiss(t *testing.T) { | |
| 53 | + | start := time.Unix(100, 0) | |
| 54 | + | q := NewToastQueue[string](ToastQueueOptions{MaxVisible: 1}) | |
| 55 | + | _, ok := q.NextDeadline() | |
| 56 | + | require.False(t, ok) | |
| 57 | + | ||
| 58 | + | q.Push(start, QueuedToast[string]{ID: "a", Duration: time.Second}) | |
| 59 | + | q.Push(start, QueuedToast[string]{ID: "b", Duration: time.Second}) | |
| 60 | + | due, ok := q.NextDeadline() | |
| 61 | + | require.True(t, ok) | |
| 62 | + | require.Equal(t, start.Add(time.Second), due) | |
| 63 | + | ||
| 64 | + | require.True(t, q.Dismiss(start.Add(time.Millisecond), "a")) | |
| 65 | + | require.False(t, q.Dismiss(start.Add(time.Millisecond), "missing")) | |
| 66 | + | require.Equal(t, "b", q.Visible(start.Add(time.Millisecond))[0].ID) | |
| 67 | + | due, _ = q.NextDeadline() | |
| 68 | + | require.Equal(t, start.Add(time.Second+time.Millisecond), due, "a promoted toast gets its full lifetime") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestToastStackBounds(t *testing.T) { | |
| 72 | + | base := domain.Size{Cols: 80, Rows: 12} | |
| 73 | + | msg := "hello" | |
| 74 | + | stack := func(anchor domain.Anchor, n int) []Toast { | |
| 75 | + | out := make([]Toast, n) | |
| 76 | + | for i := range out { | |
| 77 | + | out[i] = Toast{Message: msg, Anchor: anchor} | |
| 78 | + | } | |
| 79 | + | return out | |
| 80 | + | } | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | toast []Toast | |
| 84 | + | wantY []int | |
| 85 | + | }{ | |
| 86 | + | {name: "top grows down", toast: stack(domain.AnchorTopRight, 3), wantY: []int{2, 5, 8}}, | |
| 87 | + | {name: "bottom grows up", toast: stack(domain.AnchorBottomLeft, 2), wantY: []int{7, 4}}, | |
| 88 | + | {name: "boxes leaving the frame are dropped", toast: stack(domain.AnchorTopRight, 5), wantY: []int{2, 5, 8}}, | |
| 89 | + | {name: "empty", toast: nil, wantY: nil}, | |
| 90 | + | } | |
| 91 | + | for _, tt := range tests { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | var got []int | |
| 94 | + | for _, r := range ToastStackBounds(base, tt.toast) { | |
| 95 | + | got = append(got, r.Y) | |
| 96 | + | } | |
| 97 | + | require.Equal(t, tt.wantY, got) | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func nilIfEmpty(s []string) []string { | |
| 103 | + | if len(s) == 0 { | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | return s | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestToastQueue(t *testing.T) { | |||
| 12 | + | start := time.Unix(100, 0) | |||
| 13 | + | life := 4 * time.Second | |||
| 14 | + | toast := func(id string) QueuedToast[string] { return QueuedToast[string]{ID: id, Value: id, Duration: life} } | |||
| 15 | + | ids := func(ts []QueuedToast[string]) []string { | |||
| 16 | + | out := make([]string, len(ts)) | |||
| 17 | + | for i, t := range ts { | |||
| 18 | + | out[i] = t.ID | |||
| 19 | + | } | |||
| 20 | + | return out | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | tests := []struct { | |||
| 24 | + | name string | |||
| 25 | + | opts ToastQueueOptions | |||
| 26 | + | push []QueuedToast[string] | |||
| 27 | + | at time.Duration | |||
| 28 | + | wantVisible []string | |||
| 29 | + | wantPending int | |||
| 30 | + | }{ | |||
| 31 | + | {name: "fills slots newest first", opts: ToastQueueOptions{MaxVisible: 3}, push: []QueuedToast[string]{toast("a"), toast("b")}, wantVisible: []string{"b", "a"}}, | |||
| 32 | + | {name: "overflow waits", opts: ToastQueueOptions{MaxVisible: 2}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, wantVisible: []string{"b", "a"}, wantPending: 1}, | |||
| 33 | + | {name: "expiry promotes waiting toasts", opts: ToastQueueOptions{MaxVisible: 2}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, at: life, wantVisible: []string{"c"}}, | |||
| 34 | + | {name: "late poll paces the burst", opts: ToastQueueOptions{MaxVisible: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, at: 2 * life, wantVisible: []string{"c"}}, | |||
| 35 | + | {name: "same id replaces in place", opts: ToastQueueOptions{MaxVisible: 3}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("a")}, wantVisible: []string{"a", "b"}}, | |||
| 36 | + | {name: "same id replaces waiting", opts: ToastQueueOptions{MaxVisible: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("b")}, wantVisible: []string{"a"}, wantPending: 1}, | |||
| 37 | + | {name: "full queue drops oldest waiting", opts: ToastQueueOptions{MaxVisible: 1, MaxPending: 1}, push: []QueuedToast[string]{toast("a"), toast("b"), toast("c")}, wantVisible: []string{"a"}, wantPending: 1}, | |||
| 38 | + | {name: "persistent toast never expires", opts: ToastQueueOptions{}, push: []QueuedToast[string]{{ID: "p"}}, at: time.Hour, wantVisible: []string{"p"}}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | q := NewToastQueue[string](tt.opts) | |||
| 43 | + | for _, p := range tt.push { | |||
| 44 | + | q.Push(start, p) | |||
| 45 | + | } | |||
| 46 | + | require.Equal(t, tt.wantVisible, nilIfEmpty(ids(q.Visible(start.Add(tt.at))))) | |||
| 47 | + | require.Equal(t, tt.wantPending, q.Pending()) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestToastQueueDeadlineAndDismiss(t *testing.T) { | |||
| 53 | + | start := time.Unix(100, 0) | |||
| 54 | + | q := NewToastQueue[string](ToastQueueOptions{MaxVisible: 1}) | |||
| 55 | + | _, ok := q.NextDeadline() | |||
| 56 | + | require.False(t, ok) | |||
| 57 | + | ||||
| 58 | + | q.Push(start, QueuedToast[string]{ID: "a", Duration: time.Second}) | |||
| 59 | + | q.Push(start, QueuedToast[string]{ID: "b", Duration: time.Second}) | |||
| 60 | + | due, ok := q.NextDeadline() | |||
| 61 | + | require.True(t, ok) | |||
| 62 | + | require.Equal(t, start.Add(time.Second), due) | |||
| 63 | + | ||||
| 64 | + | require.True(t, q.Dismiss(start.Add(time.Millisecond), "a")) | |||
| 65 | + | require.False(t, q.Dismiss(start.Add(time.Millisecond), "missing")) | |||
| 66 | + | require.Equal(t, "b", q.Visible(start.Add(time.Millisecond))[0].ID) | |||
| 67 | + | due, _ = q.NextDeadline() | |||
| 68 | + | require.Equal(t, start.Add(time.Second+time.Millisecond), due, "a promoted toast gets its full lifetime") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestToastStackBounds(t *testing.T) { | |||
| 72 | + | base := domain.Size{Cols: 80, Rows: 12} | |||
| 73 | + | msg := "hello" | |||
| 74 | + | stack := func(anchor domain.Anchor, n int) []Toast { | |||
| 75 | + | out := make([]Toast, n) | |||
| 76 | + | for i := range out { | |||
| 77 | + | out[i] = Toast{Message: msg, Anchor: anchor} | |||
| 78 | + | } | |||
| 79 | + | return out | |||
| 80 | + | } | |||
| 81 | + | tests := []struct { | |||
| 82 | + | name string | |||
| 83 | + | toast []Toast | |||
| 84 | + | wantY []int | |||
| 85 | + | }{ | |||
| 86 | + | {name: "top grows down", toast: stack(domain.AnchorTopRight, 3), wantY: []int{2, 5, 8}}, | |||
| 87 | + | {name: "bottom grows up", toast: stack(domain.AnchorBottomLeft, 2), wantY: []int{7, 4}}, | |||
| 88 | + | {name: "boxes leaving the frame are dropped", toast: stack(domain.AnchorTopRight, 5), wantY: []int{2, 5, 8}}, | |||
| 89 | + | {name: "empty", toast: nil, wantY: nil}, | |||
| 90 | + | } | |||
| 91 | + | for _, tt := range tests { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | var got []int | |||
| 94 | + | for _, r := range ToastStackBounds(base, tt.toast) { | |||
| 95 | + | got = append(got, r.Y) | |||
| 96 | + | } | |||
| 97 | + | require.Equal(t, tt.wantY, got) | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func nilIfEmpty(s []string) []string { | |||
| 103 | + | if len(s) == 0 { | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | return s | |||
| 107 | + | } | |||
M internal/usecase/ui/toast_test.go +5 -161
| @@ -3,84 +3,11 @@ package ui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "testing" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | renderer "github.com/bnema/vev-vt" | |
| 9 | 8 | "github.com/bnema/vev/internal/domain" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | - | func TestToastManagerDurationDismissAndClear(t *testing.T) { | |
| 13 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |
| 14 | - | ||
| 15 | - | t.Run("show returns active immediately", func(t *testing.T) { | |
| 16 | - | m := NewToastManager() | |
| 17 | - | m.Show(now, Toast{ID: "one", Message: "hello"}) | |
| 18 | - | active := m.Active(now) | |
| 19 | - | if len(active) != 1 || active[0].ID != "one" { | |
| 20 | - | t.Fatalf("Active() = %+v, want one active toast", active) | |
| 21 | - | } | |
| 22 | - | }) | |
| 23 | - | ||
| 24 | - | t.Run("zero duration remains until dismissed", func(t *testing.T) { | |
| 25 | - | m := NewToastManager() | |
| 26 | - | m.Show(now, Toast{ID: "persistent", Duration: 0}) | |
| 27 | - | if !m.HasActive(now.Add(time.Hour)) { | |
| 28 | - | t.Fatalf("HasActive() = false, want persistent toast after one hour") | |
| 29 | - | } | |
| 30 | - | }) | |
| 31 | - | ||
| 32 | - | t.Run("positive duration expires at deadline", func(t *testing.T) { | |
| 33 | - | m := NewToastManager() | |
| 34 | - | m.Show(now, Toast{ID: "timed", Duration: time.Minute}) | |
| 35 | - | if !m.HasActive(now.Add(time.Minute - time.Nanosecond)) { | |
| 36 | - | t.Fatalf("HasActive() before deadline = false, want true") | |
| 37 | - | } | |
| 38 | - | if m.HasActive(now.Add(time.Minute)) { | |
| 39 | - | t.Fatalf("HasActive() at deadline = true, want false") | |
| 40 | - | } | |
| 41 | - | if got := m.Active(now.Add(2 * time.Minute)); len(got) != 0 { | |
| 42 | - | t.Fatalf("Active() after deadline = %+v, want none", got) | |
| 43 | - | } | |
| 44 | - | }) | |
| 45 | - | ||
| 46 | - | t.Run("dismiss removes only matching toast", func(t *testing.T) { | |
| 47 | - | m := NewToastManager() | |
| 48 | - | m.Show(now, Toast{ID: "keep"}) | |
| 49 | - | m.Show(now, Toast{ID: "remove"}) | |
| 50 | - | m.Dismiss("remove") | |
| 51 | - | active := m.Active(now) | |
| 52 | - | if len(active) != 1 || active[0].ID != "keep" { | |
| 53 | - | t.Fatalf("Active() after dismiss = %+v, want only keep", active) | |
| 54 | - | } | |
| 55 | - | }) | |
| 56 | - | ||
| 57 | - | t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) { | |
| 58 | - | anchors := []domain.Anchor{ | |
| 59 | - | domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, | |
| 60 | - | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight, | |
| 61 | - | } | |
| 62 | - | for _, anchor := range anchors { | |
| 63 | - | m := NewToastManager() | |
| 64 | - | m.Show(now, Toast{Message: "first", Anchor: anchor}) | |
| 65 | - | m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor}) | |
| 66 | - | active := m.Active(now.Add(time.Second)) | |
| 67 | - | if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() { | |
| 68 | - | t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active) | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | }) | |
| 72 | - | ||
| 73 | - | t.Run("clear removes all toasts", func(t *testing.T) { | |
| 74 | - | m := NewToastManager() | |
| 75 | - | m.Show(now, Toast{ID: "one"}) | |
| 76 | - | m.Show(now, Toast{ID: "two"}) | |
| 77 | - | m.Clear() | |
| 78 | - | if got := m.Active(now); len(got) != 0 { | |
| 79 | - | t.Fatalf("Active() after Clear() = %+v, want none", got) | |
| 80 | - | } | |
| 81 | - | }) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | 11 | func TestTruncateText(t *testing.T) { | |
| 85 | 12 | tests := []struct { | |
| 86 | 13 | name string | |
| @@ -142,7 +69,7 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |||
| 142 | 69 | } | |
| 143 | 70 | } | |
| 144 | 71 | ||
| 145 | - | func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |
| 72 | + | func TestDrawToastDrawsBorderedToastAndTruncates(t *testing.T) { | |
| 146 | 73 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | |
| 147 | 74 | ||
| 148 | 75 | t.Run("bordered centered toast preserves exterior", func(t *testing.T) { | |
| @@ -150,7 +77,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 150 | 77 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 151 | 78 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | |
| 152 | 79 | ||
| 153 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil) | |
| 80 | + | DrawToast(f, Toast{Message: "Hi", Anchor: domain.AnchorCenter}, styles) | |
| 154 | 81 | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | |
| 155 | 82 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | |
| 156 | 83 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | |
| @@ -169,7 +96,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 169 | 96 | ||
| 170 | 97 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | |
| 171 | 98 | f := renderer.NewFrame(8, 3) | |
| 172 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil) | |
| 99 | + | DrawToast(f, Toast{Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}, styles) | |
| 173 | 100 | assertRune(t, f, 0, 0, '┌') | |
| 174 | 101 | assertRune(t, f, 7, 0, '┐') | |
| 175 | 102 | assertRune(t, f, 2, 1, 'a') | |
| @@ -179,95 +106,12 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 179 | 106 | }) | |
| 180 | 107 | } | |
| 181 | 108 | ||
| 182 | - | func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |
| 183 | - | f := renderer.NewFrame(30, 5) | |
| 184 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |
| 185 | - | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | |
| 186 | - | ||
| 187 | - | CompositeToasts(f, []ActiveToast{ | |
| 188 | - | {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now}, | |
| 189 | - | {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)}, | |
| 190 | - | {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now}, | |
| 191 | - | }, styles, nil) | |
| 192 | - | ||
| 193 | - | assertRune(t, f, 4, 3, 'N') | |
| 194 | - | assertRune(t, f, 5, 3, 'e') | |
| 195 | - | assertRune(t, f, 6, 3, 'w') | |
| 196 | - | for y := 0; y < f.Height; y++ { | |
| 197 | - | for x := 0; x < f.Width; x++ { | |
| 198 | - | if f.At(x, y).Rune == 'O' { | |
| 199 | - | t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y) | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | func TestLatestToastsByAnchorTieAndOrder(t *testing.T) { | |
| 206 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |
| 207 | - | anchors := []domain.Anchor{ | |
| 208 | - | domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft, | |
| 209 | - | domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop, | |
| 210 | - | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom, | |
| 211 | - | } | |
| 212 | - | toasts := make([]ActiveToast, 0, len(anchors)+1) | |
| 213 | - | for _, anchor := range anchors { | |
| 214 | - | toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now}) | |
| 215 | - | } | |
| 216 | - | toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now}) | |
| 217 | - | ||
| 218 | - | visible := latestToastsByAnchor(toasts) | |
| 219 | - | if len(visible) != len(anchors) { | |
| 220 | - | t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors)) | |
| 221 | - | } | |
| 222 | - | for i, anchor := range anchors { | |
| 223 | - | if visible[i].Anchor != anchor { | |
| 224 | - | t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor) | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | if visible[0].ID != "later-input" { | |
| 228 | - | t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID) | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |
| 233 | - | f := renderer.NewFrame(14, 5) | |
| 234 | - | baseStyle := renderer.Style{Foreground: 1, Background: 2} | |
| 235 | - | dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true} | |
| 236 | - | for y := 0; y < f.Height; y++ { | |
| 237 | - | for x := 0; x < f.Width; x++ { | |
| 238 | - | f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle}) | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | |
| 242 | - | ||
| 243 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { | |
| 244 | - | return dimStyle | |
| 245 | - | }) | |
| 246 | - | ||
| 247 | - | outside := f.At(0, 0) | |
| 248 | - | if outside.Rune != 'a' { | |
| 249 | - | t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune) | |
| 250 | - | } | |
| 251 | - | if !outside.Style.Equal(dimStyle) { | |
| 252 | - | t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle) | |
| 253 | - | } | |
| 254 | - | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | |
| 255 | - | boxCell := f.At(bounds.X, bounds.Y) | |
| 256 | - | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | |
| 257 | - | t.Fatalf("box cell = %+v, want toast box style", boxCell) | |
| 258 | - | } | |
| 259 | - | textCell := f.At(bounds.X+2, bounds.Y+1) | |
| 260 | - | if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) { | |
| 261 | - | t.Fatalf("text cell = %+v, want toast text style", textCell) | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | func TestCompositeToastsHandlesTinyFrames(t *testing.T) { | |
| 109 | + | func TestDrawToastHandlesTinyFrames(t *testing.T) { | |
| 266 | 110 | styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()} | |
| 267 | 111 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | |
| 268 | 112 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |
| 269 | 113 | f := renderer.NewFrame(size.Cols, size.Rows) | |
| 270 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil) | |
| 114 | + | DrawToast(f, Toast{Message: "abcdef", Anchor: domain.AnchorCenter}, styles) | |
| 271 | 115 | }) | |
| 272 | 116 | } | |
| 273 | 117 | } | |
| @@ -3,84 +3,11 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | - | "time" | |||
| 7 | 6 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 7 | renderer "github.com/bnema/vev-vt" | ||
| 9 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | - | func TestToastManagerDurationDismissAndClear(t *testing.T) { | |||
| 13 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |||
| 14 | - | ||||
| 15 | - | t.Run("show returns active immediately", func(t *testing.T) { | |||
| 16 | - | m := NewToastManager() | |||
| 17 | - | m.Show(now, Toast{ID: "one", Message: "hello"}) | |||
| 18 | - | active := m.Active(now) | |||
| 19 | - | if len(active) != 1 || active[0].ID != "one" { | |||
| 20 | - | t.Fatalf("Active() = %+v, want one active toast", active) | |||
| 21 | - | } | |||
| 22 | - | }) | |||
| 23 | - | ||||
| 24 | - | t.Run("zero duration remains until dismissed", func(t *testing.T) { | |||
| 25 | - | m := NewToastManager() | |||
| 26 | - | m.Show(now, Toast{ID: "persistent", Duration: 0}) | |||
| 27 | - | if !m.HasActive(now.Add(time.Hour)) { | |||
| 28 | - | t.Fatalf("HasActive() = false, want persistent toast after one hour") | |||
| 29 | - | } | |||
| 30 | - | }) | |||
| 31 | - | ||||
| 32 | - | t.Run("positive duration expires at deadline", func(t *testing.T) { | |||
| 33 | - | m := NewToastManager() | |||
| 34 | - | m.Show(now, Toast{ID: "timed", Duration: time.Minute}) | |||
| 35 | - | if !m.HasActive(now.Add(time.Minute - time.Nanosecond)) { | |||
| 36 | - | t.Fatalf("HasActive() before deadline = false, want true") | |||
| 37 | - | } | |||
| 38 | - | if m.HasActive(now.Add(time.Minute)) { | |||
| 39 | - | t.Fatalf("HasActive() at deadline = true, want false") | |||
| 40 | - | } | |||
| 41 | - | if got := m.Active(now.Add(2 * time.Minute)); len(got) != 0 { | |||
| 42 | - | t.Fatalf("Active() after deadline = %+v, want none", got) | |||
| 43 | - | } | |||
| 44 | - | }) | |||
| 45 | - | ||||
| 46 | - | t.Run("dismiss removes only matching toast", func(t *testing.T) { | |||
| 47 | - | m := NewToastManager() | |||
| 48 | - | m.Show(now, Toast{ID: "keep"}) | |||
| 49 | - | m.Show(now, Toast{ID: "remove"}) | |||
| 50 | - | m.Dismiss("remove") | |||
| 51 | - | active := m.Active(now) | |||
| 52 | - | if len(active) != 1 || active[0].ID != "keep" { | |||
| 53 | - | t.Fatalf("Active() after dismiss = %+v, want only keep", active) | |||
| 54 | - | } | |||
| 55 | - | }) | |||
| 56 | - | ||||
| 57 | - | t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) { | |||
| 58 | - | anchors := []domain.Anchor{ | |||
| 59 | - | domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, | |||
| 60 | - | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight, | |||
| 61 | - | } | |||
| 62 | - | for _, anchor := range anchors { | |||
| 63 | - | m := NewToastManager() | |||
| 64 | - | m.Show(now, Toast{Message: "first", Anchor: anchor}) | |||
| 65 | - | m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor}) | |||
| 66 | - | active := m.Active(now.Add(time.Second)) | |||
| 67 | - | if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() { | |||
| 68 | - | t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active) | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | }) | |||
| 72 | - | ||||
| 73 | - | t.Run("clear removes all toasts", func(t *testing.T) { | |||
| 74 | - | m := NewToastManager() | |||
| 75 | - | m.Show(now, Toast{ID: "one"}) | |||
| 76 | - | m.Show(now, Toast{ID: "two"}) | |||
| 77 | - | m.Clear() | |||
| 78 | - | if got := m.Active(now); len(got) != 0 { | |||
| 79 | - | t.Fatalf("Active() after Clear() = %+v, want none", got) | |||
| 80 | - | } | |||
| 81 | - | }) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | func TestTruncateText(t *testing.T) { | 11 | func TestTruncateText(t *testing.T) { | ||
| 85 | tests := []struct { | 12 | tests := []struct { | ||
| 86 | name string | 13 | name string | ||
| @@ -142,7 +69,7 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |||||
| 142 | } | 69 | } | ||
| 143 | } | 70 | } | ||
| 144 | 71 | ||||
| 145 | - | func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | 72 | + | func TestDrawToastDrawsBorderedToastAndTruncates(t *testing.T) { |
| 146 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | 73 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | ||
| 147 | 74 | ||||
| 148 | t.Run("bordered centered toast preserves exterior", func(t *testing.T) { | 75 | t.Run("bordered centered toast preserves exterior", func(t *testing.T) { | ||
| @@ -150,7 +77,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||||
| 150 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | 77 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | ||
| 151 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | 78 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | ||
| 152 | 79 | ||||
| 153 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil) | 80 | + | DrawToast(f, Toast{Message: "Hi", Anchor: domain.AnchorCenter}, styles) |
| 154 | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | 81 | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | ||
| 155 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | 82 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | ||
| 156 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | 83 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | ||
| @@ -169,7 +96,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||||
| 169 | 96 | ||||
| 170 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | 97 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | ||
| 171 | f := renderer.NewFrame(8, 3) | 98 | f := renderer.NewFrame(8, 3) | ||
| 172 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil) | 99 | + | DrawToast(f, Toast{Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}, styles) |
| 173 | assertRune(t, f, 0, 0, '┌') | 100 | assertRune(t, f, 0, 0, '┌') | ||
| 174 | assertRune(t, f, 7, 0, '┐') | 101 | assertRune(t, f, 7, 0, '┐') | ||
| 175 | assertRune(t, f, 2, 1, 'a') | 102 | assertRune(t, f, 2, 1, 'a') | ||
| @@ -179,95 +106,12 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||||
| 179 | }) | 106 | }) | ||
| 180 | } | 107 | } | ||
| 181 | 108 | ||||
| 182 | - | func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | 109 | + | func TestDrawToastHandlesTinyFrames(t *testing.T) { |
| 183 | - | f := renderer.NewFrame(30, 5) | |||
| 184 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |||
| 185 | - | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | |||
| 186 | - | ||||
| 187 | - | CompositeToasts(f, []ActiveToast{ | |||
| 188 | - | {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now}, | |||
| 189 | - | {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)}, | |||
| 190 | - | {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now}, | |||
| 191 | - | }, styles, nil) | |||
| 192 | - | ||||
| 193 | - | assertRune(t, f, 4, 3, 'N') | |||
| 194 | - | assertRune(t, f, 5, 3, 'e') | |||
| 195 | - | assertRune(t, f, 6, 3, 'w') | |||
| 196 | - | for y := 0; y < f.Height; y++ { | |||
| 197 | - | for x := 0; x < f.Width; x++ { | |||
| 198 | - | if f.At(x, y).Rune == 'O' { | |||
| 199 | - | t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y) | |||
| 200 | - | } | |||
| 201 | - | } | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | func TestLatestToastsByAnchorTieAndOrder(t *testing.T) { | |||
| 206 | - | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |||
| 207 | - | anchors := []domain.Anchor{ | |||
| 208 | - | domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft, | |||
| 209 | - | domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop, | |||
| 210 | - | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom, | |||
| 211 | - | } | |||
| 212 | - | toasts := make([]ActiveToast, 0, len(anchors)+1) | |||
| 213 | - | for _, anchor := range anchors { | |||
| 214 | - | toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now}) | |||
| 215 | - | } | |||
| 216 | - | toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now}) | |||
| 217 | - | ||||
| 218 | - | visible := latestToastsByAnchor(toasts) | |||
| 219 | - | if len(visible) != len(anchors) { | |||
| 220 | - | t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors)) | |||
| 221 | - | } | |||
| 222 | - | for i, anchor := range anchors { | |||
| 223 | - | if visible[i].Anchor != anchor { | |||
| 224 | - | t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor) | |||
| 225 | - | } | |||
| 226 | - | } | |||
| 227 | - | if visible[0].ID != "later-input" { | |||
| 228 | - | t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID) | |||
| 229 | - | } | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||
| 233 | - | f := renderer.NewFrame(14, 5) | |||
| 234 | - | baseStyle := renderer.Style{Foreground: 1, Background: 2} | |||
| 235 | - | dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true} | |||
| 236 | - | for y := 0; y < f.Height; y++ { | |||
| 237 | - | for x := 0; x < f.Width; x++ { | |||
| 238 | - | f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle}) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | |||
| 242 | - | ||||
| 243 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { | |||
| 244 | - | return dimStyle | |||
| 245 | - | }) | |||
| 246 | - | ||||
| 247 | - | outside := f.At(0, 0) | |||
| 248 | - | if outside.Rune != 'a' { | |||
| 249 | - | t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune) | |||
| 250 | - | } | |||
| 251 | - | if !outside.Style.Equal(dimStyle) { | |||
| 252 | - | t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle) | |||
| 253 | - | } | |||
| 254 | - | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | |||
| 255 | - | boxCell := f.At(bounds.X, bounds.Y) | |||
| 256 | - | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | |||
| 257 | - | t.Fatalf("box cell = %+v, want toast box style", boxCell) | |||
| 258 | - | } | |||
| 259 | - | textCell := f.At(bounds.X+2, bounds.Y+1) | |||
| 260 | - | if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) { | |||
| 261 | - | t.Fatalf("text cell = %+v, want toast text style", textCell) | |||
| 262 | - | } | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | func TestCompositeToastsHandlesTinyFrames(t *testing.T) { | |||
| 266 | styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()} | 110 | styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()} | ||
| 267 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | 111 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | ||
| 268 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | 112 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | ||
| 269 | f := renderer.NewFrame(size.Cols, size.Rows) | 113 | f := renderer.NewFrame(size.Cols, size.Rows) | ||
| 270 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil) | 114 | + | DrawToast(f, Toast{Message: "abcdef", Anchor: domain.AnchorCenter}, styles) |
| 271 | }) | 115 | }) | ||
| 272 | } | 116 | } | ||
| 273 | } | 117 | } | ||