detached cd9b41f

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 UnverifiedB5690EEEBB952194 The key is unknown
18 files · +698 -509
M internal/domain/notice.go +1 -1
@@ -129,7 +129,7 @@ type Notification struct {
129129
130130 // Subject is the stable identity of what a notice is about. Client toasts
131131 // 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).
133133 func (n Notification) Subject() string {
134134 return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope
135135 }
@@ -129,7 +129,7 @@ type Notification struct {
129 129
130 // Subject is the stable identity of what a notice is about. Client toasts130 // 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.Scope134 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 {
4545 return nil
4646 }
4747 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{
4949 Text: renderer.DefaultStyle(),
5050 Box: renderer.DefaultStyle(),
51- }, nil)
51+ })
5252 lines := make([]string, bounds.Height)
5353 for y := range bounds.Height {
5454 var b strings.Builder
@@ -83,6 +83,16 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
8383 return lines
8484 }
8585
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+
8696 func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error {
8797 if _, err := io.WriteString(out, "\x1b[s"); err != nil {
8898 return err
@@ -45,10 +45,10 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
45 return nil45 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.Builder54 var b strings.Builder
@@ -83,6 +83,16 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
83 return lines83 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 err98 return err
M internal/usecase/client/picker_composition.go +10 -3
@@ -138,6 +138,13 @@ func (p *Picker) Notify(n domain.Notification) {
138138 }
139139 }
140140
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+
141148 // invalidatePresentation forces the next frame to redraw the whole box.
142149 func (p *Picker) invalidatePresentation() {
143150 if p != nil && p.controller != nil {
@@ -173,9 +180,9 @@ func (p *Picker) Render(size domain.Size) []byte {
173180 return p.controller.Render(size)
174181 }
175182
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.
179186 func (p *Picker) RenderNotice(size domain.Size) []byte {
180187 if p == nil || p.controller == nil {
181188 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 no183+// 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 only184+// 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 nil188 return nil
M internal/usecase/client/picker_controller.go +83 -28
@@ -3,6 +3,7 @@ package client
33 import (
44 "bytes"
55 "errors"
6+ "slices"
67 "sync"
78 "time"
89
@@ -83,6 +84,8 @@ const (
8384 pickerGeneration = uint64(1)
8485 // pickerNoticeLifetime bounds how long one catalogue notice is displayed.
8586 pickerNoticeLifetime = 4 * time.Second
87+ // pickerNoticeVisible is how many notices stack at once; later ones wait.
88+ pickerNoticeVisible = 3
8689 )
8790
8891 // pickerController owns one client-owned picker: catalogue, model, renderer,
@@ -92,12 +95,15 @@ type pickerController struct {
9295 clock ports.Clock
9396 catalogue *pickerCatalogue
9497
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
101107 // sort is this client's picker ordering. It survives every picker
102108 // reopen for the lifetime of the client process.
103109 sort pickerusecase.SortMode
@@ -130,6 +136,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration, trueColor b
130136 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
131137 renderer: newPickerRenderer(pickerColorProfile(trueColor)),
132138 sort: defaultPickerSort(),
139+ notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
133140 ownsInput: true,
134141 opsReady: make(chan struct{}, 1),
135142 }
@@ -360,6 +367,8 @@ func (p *pickerController) invalidatePresentation() {
360367 }
361368 p.mu.Lock()
362369 p.renderer.invalidate()
370+ // Another owner repainted the screen: the old toast cells are gone.
371+ p.noticeBounds = nil
363372 p.mu.Unlock()
364373 }
365374
@@ -385,7 +394,14 @@ func (p *pickerController) Render(size domain.Size) []byte {
385394 if p.loop == nil {
386395 return nil
387396 }
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)...)
389405 }
390406
391407 // SetPreview displays one preview frame in the given state. A frame is shown
@@ -468,37 +484,75 @@ func (p *pickerController) ClearPreview() {
468484 p.mu.Unlock()
469485 }
470486
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.
474492 func (p *pickerController) RenderNotice(size domain.Size) []byte {
475493 if p == nil {
476494 return nil
477495 }
478496 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()
483502 }
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)))
493506 }
494- if newest.Message == "" {
507+ if buffer.Len() == 0 {
495508 return nil
496509 }
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)
497523 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+ }
500533 }
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()
502556 }
503557
504558 // CursorKey reports the opaque key of the row under the cursor, or false when
@@ -620,6 +674,7 @@ func (p *pickerController) SetCurrent(current pickerCurrent) {
620674 p.loop = nil
621675 p.rebuildLocked()
622676 p.renderer.invalidate()
677+ p.noticeBounds = nil
623678 }
624679
625680 // rememberSortLocked keeps the sort the user chose in the closing
@@ -751,7 +806,7 @@ func (p *pickerController) notifyLocked(n domain.Notification) {
751806 if n.Message == "" {
752807 return
753808 }
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}})
755810 }
756811
757812 // 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.Second86 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.Clock95 clock ports.Clock
93 catalogue *pickerCatalogue96 catalogue *pickerCatalogue
94 97
95- mu sync.Mutex98+ mu sync.Mutex
96- loop *pickerLoop99+ loop *pickerLoop
97- consumer pickerConsumer100+ consumer pickerConsumer
98- renderer *pickerRenderer101+ renderer *pickerRenderer
99- notices ui.ToastManager102+ notices *ui.ToastQueue[ui.Toast]
100- interaction uint64103+ // 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 picker107 // 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.SortMode109 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 nil395 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 shown407 // 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-local487+// RenderNotice composes the live notice stack as client-local toasts: the
472-// toast. It reuses the client's existing toast rendering and returns nil when488+// 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 nil494 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 nil500+ // 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 last503+ buffer := bytes.NewBuffer(blanks)
485- // one" is arbitrary and can change between frames. Select the newest by504+ 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 nil508 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.Buffer523 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 nil525+ 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 when558 // 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 = nil674 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 closing680 // 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 return807 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. Close812 // mergePickerOps accumulates two presentation decisions from one read. Close
M internal/usecase/client/picker_controller_test.go +50 -24
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "io"
7+ "strings"
78 "sync"
89 "testing"
910 "time"
@@ -579,11 +580,11 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
579580 require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
580581
581582 controller.mu.Lock()
582- active := controller.notices.Active(clock.Now())
583+ active := controller.notices.Visible(clock.Now())
583584 controller.mu.Unlock()
584585 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@")
587588 }
588589
589590 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
@@ -596,7 +597,7 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
596597 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
597598
598599 controller.mu.Lock()
599- active := controller.notices.Active(clock.Now())
600+ active := controller.notices.Visible(clock.Now())
600601 controller.mu.Unlock()
601602 require.Empty(t, active)
602603 lines := controller.Catalogue().Lines()
@@ -606,26 +607,51 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
606607 require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail)
607608 }
608609
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) {
614614 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+ })
630656 }
631657 }
@@ -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 the590 // 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 is610+// TestPickerControllerRenderNoticeStacksQueuesAndBlanks pins the picker notice
610-// the newest by ShownAt (tie-broken by ID), independent of the toast manager's611+// 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) {
525525 controller.mu.Lock()
526526 defer controller.mu.Unlock()
527527 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)
530530 }
531531 require.Len(t, messages, 2, "each host keeps one toast")
532532 require.Contains(t, messages, "Remote host reconnected: user@arch")
@@ -549,21 +549,21 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
549549 return o
550550 }
551551 revision := ports.BrokerRevision(0)
552- apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast {
552+ apply := func(daemons ...ports.BrokerDaemonObservation) []ui.QueuedToast[ui.Toast] {
553553 revision++
554554 controller.mu.Lock()
555- controller.notices.Clear()
555+ controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
556556 controller.mu.Unlock()
557557 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons})
558558 controller.mu.Lock()
559559 defer controller.mu.Unlock()
560- return controller.notices.Active(clock.Now())
560+ return controller.notices.Visible(clock.Now())
561561 }
562562
563563 toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust))
564564 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")
567567 require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once")
568568 require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again")
569569
@@ -571,7 +571,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
571571 recovered.FailureEpisode = 2
572572 toasts = apply(recovered)
573573 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)
575575 require.Empty(t, apply(recovered), "a steady healthy host is silent")
576576
577577 require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1)
@@ -586,7 +586,7 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
586586
587587 toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
588588 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")
590590 }
591591
592592 // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
@@ -609,7 +609,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
609609 o.Availability = tt.availability
610610 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}})
611611 controller.mu.Lock()
612- controller.notices.Clear()
612+ controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
613613 controller.mu.Unlock()
614614
615615 _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
@@ -619,10 +619,10 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
619619 }
620620 require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable))
621621 controller.mu.Lock()
622- active := controller.notices.Active(clock.Now())
622+ active := controller.notices.Visible(clock.Now())
623623 controller.mu.Unlock()
624624 require.Len(t, active, 1)
625- require.Equal(t, tt.wantNotice, active[0].Message)
625+ require.Equal(t, tt.wantNotice, active[0].Value.Message)
626626 })
627627 }
628628 }
@@ -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 o549 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 = 2571 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 a592 // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
@@ -609,7 +609,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
609 o.Availability = tt.availability609 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
908908 }
909909 ops = s.cfg.Picker.OpsReady()
910910 }
911+ notices := noticeWake{clock: s.cfg.Clock}
912+ defer notices.stop()
911913 for {
912914 select {
915+ case <-notices.arm(s.cfg.Picker):
916+ s.renderCurrent()
913917 case <-service.Done():
914918 return readyOutcome{lossErr: service.Err()}
915919 case <-changed:
@@ -1017,6 +1021,45 @@ func (s *Supervisor) notifyPicker(n domain.Notification) {
10171021 }
10181022 }
10191023
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+
10201063 // pickerExitHint tells an unattached user how to leave the picker.
10211064 var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"}
10221065
@@ -1032,6 +1075,8 @@ func (s *Supervisor) invalidatePickerPresentation() {
10321075 // failure until the terminal ends or the process is cancelled. A clean terminal
10331076 // EOF returns nil; every other cause is reported.
10341077 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {
1078+ notices := noticeWake{clock: s.cfg.Clock}
1079+ defer notices.stop()
10351080 for {
10361081 select {
10371082 case <-s.pickerOps():
@@ -1044,6 +1089,8 @@ func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputL
10441089 return terminalReadCause(err)
10451090 case <-s.presentationInvalidation():
10461091 s.renderResizeInvalidation()
1092+ case <-notices.arm(s.cfg.Picker):
1093+ s.renderCurrent()
10471094 }
10481095 }
10491096 }
@@ -1058,6 +1105,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
10581105 }
10591106 timer := s.cfg.Clock.NewTimer(delay)
10601107 defer stopSupervisorTimer(timer)
1108+ notices := noticeWake{clock: s.cfg.Clock}
1109+ defer notices.stop()
10611110 for {
10621111 select {
10631112 case <-s.pickerOps():
@@ -1074,6 +1123,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
10741123 s.renderResizeInvalidation()
10751124 case <-s.spinnerTick():
10761125 s.cfg.Spinner.AdvanceSpinner(s.State())
1126+ case <-notices.arm(s.cfg.Picker):
1127+ s.renderCurrent()
10771128 }
10781129 }
10791130 }
@@ -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 terminal1075 // 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
731731 var brokerLost bool
732732 var terminated bool
733733 var termErr error
734+ notices := noticeWake{clock: s.cfg.Clock}
735+ defer notices.stop()
734736 settlement:
735737 for {
736738 // Begin has admitted the foreground, which can write before MarkAttached
@@ -762,6 +764,8 @@ settlement:
762764 overlay.settleAction(consumed)
763765 case <-overlay.invalidation():
764766 overlay.resize()
767+ case <-notices.arm(s.cfg.Picker):
768+ s.renderCurrent()
765769 case outcome := <-s.kills.results():
766770 s.finishPickerKill(service, outcome)
767771 case <-service.Done():
@@ -731,6 +731,8 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
731 var brokerLost bool731 var brokerLost bool
732 var terminated bool732 var terminated bool
733 var termErr error733 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 MarkAttached738 // 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 {
139139 if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded {
140140 sev = domain.NoticeInfo
141141 }
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}
143145 }
144146
145147 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.NoticeInfo140 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) {
346346
347347 require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression")
348348 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)
352351 }
353352
354353 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 (
44 "context"
55 "errors"
66 "log/slog"
7+ "strconv"
78 "strings"
89 "sync"
910 "time"
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/ui"
1315 )
1416
1517 const (
1618 noticeHistoryCap = 200
1719 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.
2022 maxVisibleToasts = 3
2123 )
2224
@@ -135,14 +137,6 @@ func (nc *noticeCenter) drainPending() []domain.Notification {
135137 return out
136138 }
137139
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-
146140 // noticeTTL is how long a toast of this severity stays visible.
147141 func noticeTTL(sev domain.NoticeSeverity) time.Duration {
148142 switch sev {
@@ -387,6 +381,8 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
387381 // publishToast durably mutates one client's toast state without repainting.
388382 // Routing paths call it while routingMu excludes detach, then release routingMu
389383 // 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.
390386 func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
391387 if ac == nil {
392388 return false
@@ -396,66 +392,37 @@ func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool {
396392
397393 rt.noticeMu.Lock()
398394 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})
415397 }
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
429403 }
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
433407 }
434408
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
443416 }
444- return -1
417+ return id + "\x00" + strconv.Itoa(int(n.Severity)) + "\x00" + n.Message + "\x00" + n.Details
445418 }
446419
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)
456423 }
457424
458-// dismissToast removes only the specified visible toast. In particular, a
425+// dismissToastWithoutRepaint removes only the specified toast. In particular, a
459426 // link-connected event must not clear clipboard or other notice state.
460427 func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) bool {
461428 if ac == nil || ac.overlays == nil {
@@ -464,38 +431,36 @@ func (d *Daemon) dismissToastWithoutRepaint(ac *attachedClient, code domain.Noti
464431 rt := ac.overlays
465432 rt.noticeMu.Lock()
466433 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})) {
469435 return false
470436 }
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)
476438 return true
477439 }
478440
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) {
483446 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) {
488458 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)
499464 }
500465 rt.noticeMu.Unlock()
501466 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 = 20018 noticeHistoryCap = 200
17 noticePendingCap = 3219 noticePendingCap = 32
18- // maxVisibleToasts bounds the stack drawn over a client's screen; anything20+ // 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 = 322 maxVisibleToasts = 3
21 )23 )
22 24
@@ -135,14 +137,6 @@ func (nc *noticeCenter) drainPending() []domain.Notification {
135 return out137 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 routingMu382 // 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 false388 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.Count396+ 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 true399+ 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.Details406+ return true
433 }407 }
434 408
435-// indexOfMatchingToastLocked finds a visible toast with the code-specific409+// 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 i415+ return id
442- }
443 }416 }
444- return -1417+ 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, a425+// 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.overlays431 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 false435 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 true438 return true
477 }439 }
478 440
479-// retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the441+// armToastTimerLocked points the client's single toast timer at the queue's
480-// timer goroutine re-acquires it and releases it before repainting, so the442+// 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.overlays446 rt := ac.overlays
484- rt.noticeSeq++447+ due, ok := rt.noticeQueue.NextDeadline()
485- t.seq = rt.noticeSeq448+ if ok && due.Equal(rt.noticeDue) && rt.noticeTimer.timer != nil {
486- seq := t.seq449+ 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- continue462+ 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) {
300300 rt := ac.overlays
301301 rt.noticeMu.Lock()
302302 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
306305 }
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
308323 }
309324
310325 // awaitToastCount waits for the toast slice to reach want. The polled condition
@@ -788,7 +803,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) {
788803
789804 toasts := awaitToastCount(t, ac, 1)
790805 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")
792807 _, overflow := visibleToasts(ac)
793808 require.Zero(t, overflow)
794809 require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
@@ -806,7 +821,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) {
806821 require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
807822 require.Equal(t, "build started", toasts[1].Message)
808823 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")
810825 })
811826
812827 t.Run("same code different scope does not coalesce", func(t *testing.T) {
@@ -820,7 +835,7 @@ func TestShowToastCoalesceAndTrim(t *testing.T) {
820835 require.Equal(t, 1, toasts[1].Count)
821836 })
822837
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) {
824839 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
825840 codes := []domain.NoticeCode{
826841 domain.NoticePaneSpawn,
@@ -833,11 +848,11 @@ func TestShowToastCoalesceAndTrim(t *testing.T) {
833848 }
834849
835850 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{
837852 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)
841856 })
842857 }
843858
@@ -911,32 +926,25 @@ func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) {
911926 require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed")
912927 }
913928
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) {
920933 clk := newNoticeClock()
921934 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)
922942
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")
940948 }
941949
942950 // TestReportErrorClassifiedUserErrorWinsOverBenignCause proves an explicitly
@@ -300,11 +300,26 @@ func visibleToasts(ac *attachedClient) ([]domain.Notification, int) {
300 rt := ac.overlays300 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.noticeOverflow306+ 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 condition325 // 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: an929+// 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 was931+// next waiting notice instead of dropping it.
917-// stopped but had already slipped past its select cannot dismiss the refreshed932+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, without946+ 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 explicitly950 // TestReportErrorClassifiedUserErrorWinsOverBenignCause proves an explicitly
M internal/usecase/daemon/overlay_runtime.go +13 -11
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/usecase/palette"
1212 "github.com/bnema/vev/internal/usecase/picker"
1313 promptui "github.com/bnema/vev/internal/usecase/prompt"
14+ "github.com/bnema/vev/internal/usecase/ui"
1415 "github.com/bnema/vev/internal/usecase/visualsearch"
1516 )
1617
@@ -112,12 +113,14 @@ type overlayRuntime struct {
112113 // The notification history overlay's fields share this lock rather than
113114 // adding one of their own: both concern notice presentation for this
114115 // 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
121124
122125 // noticesOverlay is the `notifications` command's history modal. nil when
123126 // closed.
@@ -365,13 +368,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
365368 // noticeMu is innermost and never held across render, so it is taken and
366369 // released here rather than tracked like paletteMu/promptMu below.
367370 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)
372374 }
375+ snap.noticeOverflow = rt.noticeQueue.Pending()
373376 }
374- snap.noticeOverflow = rt.noticeOverflow
375377 snap.noticesOverlayActive = rt.noticesOverlay != nil
376378 snap.noticesOverlayModel = rt.noticesOverlay.Clone()
377379 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 than113 // The notification history overlay's fields share this lock rather than
113 // adding one of their own: both concern notice presentation for this114 // 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.Mutex116+ noticeMu sync.Mutex
116- noticeToasts []noticeToast117+ // noticeQueue is this client's toast stack and its waiting notices. nil
117- noticeOverflow int118+ // until the first notice.
118- // noticeSeq numbers toast entries so an already-fired expiry timer cannot119+ 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 uint64121+ // 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 when125 // 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 and368 // 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 != nil377 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 {
229229
230230 func countInventoryNotices(t *testing.T, ac *attachedClient) int {
231231 t.Helper()
232- ac.overlays.noticeMu.Lock()
233- defer ac.overlays.noticeMu.Unlock()
232+ toasts, _ := visibleToasts(ac)
234233 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 {
237236 count++
238237 }
239238 }
@@ -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 := 0233 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 @@
11 package ui
22
33 import (
4- "time"
5-
64 renderer "github.com/bnema/vev-vt"
75 "github.com/bnema/vev/internal/domain"
86 )
@@ -15,29 +13,15 @@ const (
1513
1614 // Toast describes a transient message rendered over a frame.
1715 type Toast struct {
18- ID string
1916 Message string
2017 // 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
4125 }
4226
4327 // ToastStyles contains styles used when drawing a toast.
@@ -46,62 +30,6 @@ type ToastStyles struct {
4630 Box renderer.Style
4731 }
4832
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-
10533 // ToastBounds returns the toast rectangle positioned within base and clamped to base.
10634 func ToastBounds(base domain.Size, toast Toast) domain.Rect {
10735 if base.Cols <= 0 || base.Rows <= 0 {
@@ -135,66 +63,8 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect {
13563 })
13664 }
13765
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) {
19868 bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast)
19969 if bounds.Width <= 0 || bounds.Height <= 0 {
20070 return
@@ -1,8 +1,6 @@
1 package ui1 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 string16 Message string
20 // Severity is carried for callers that style by it (client toast17 // 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.NoticeSeverity19+ Severity domain.NoticeSeverity
23- Anchor domain.Anchor20+ Anchor domain.Anchor
24- Duration time.Duration21+ MinWidth int
25- DimBackground bool22+ MaxWidth int
26- MinWidth int23+ PaddingX int
27- MaxWidth int24+ 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.Style30 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 return70 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
33 import (
44 "fmt"
55 "testing"
6- "time"
76
87 renderer "github.com/bnema/vev-vt"
98 "github.com/bnema/vev/internal/domain"
109 )
1110
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-
8411 func TestTruncateText(t *testing.T) {
8512 tests := []struct {
8613 name string
@@ -142,7 +69,7 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
14269 }
14370 }
14471
145-func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
72+func TestDrawToastDrawsBorderedToastAndTruncates(t *testing.T) {
14673 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
14774
14875 t.Run("bordered centered toast preserves exterior", func(t *testing.T) {
@@ -150,7 +77,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
15077 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
15178 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
15279
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)
15481 bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
15582 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
15683 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
@@ -169,7 +96,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
16996
17097 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
17198 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)
173100 assertRune(t, f, 0, 0, '┌')
174101 assertRune(t, f, 7, 0, '┐')
175102 assertRune(t, f, 2, 1, 'a')
@@ -179,95 +106,12 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
179106 })
180107 }
181108
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) {
266110 styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()}
267111 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
268112 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
269113 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)
271115 })
272116 }
273117 }
@@ -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 string13 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close