detached 74411ec

Merge pull request #25 from bnema/feat/toast-notifications

feat(ui): add reconnect toast notifications
commit
74411ec7bc9242f6f5f6488c095d5a68700011e7
author
bnema · · 2026-07-06 01:33 UTC
committer
GitHub · · 2026-07-06 01:33 UTC
merge of
338e508 + 77ed6d6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +880 -9
M internal/usecase/client/client.go +64 -9
@@ -110,6 +110,7 @@ type reconnectBackoff struct {
110110 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
111111
112112 var reconnectSleep = sleepReconnect
113+var reconnectSleepWithResize = sleepReconnectWithResizeEvents
113114
114115 const (
115116 statusReconnect = "\r\x1b[2Kreconnecting…"
@@ -165,32 +166,65 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
165166 attemptIntent := intent
166167 backoff := defaultReconnectBackoff.initial
167168 showingStatus := false
168- drawStatus := func() {
169- if !rawEntered || showingStatus {
169+ var reconnectToastSize domain.Size
170+ redrawRemoteStatus := func(size domain.Size) {
171+ if !rawEntered || !remote {
170172 return
171173 }
172- _, _ = term.Out().Write([]byte(statusReconnect))
174+ if showingStatus {
175+ _ = clearReconnectToast(term.Out(), reconnectToastSize)
176+ }
177+ _ = drawReconnectToast(term.Out(), size)
178+ reconnectToastSize = size
173179 _ = term.Flush()
174180 showingStatus = true
175181 }
182+ drawStatus := func() {
183+ if !rawEntered || showingStatus {
184+ return
185+ }
186+ if remote {
187+ size, err := term.Size()
188+ if err != nil {
189+ return
190+ }
191+ redrawRemoteStatus(size)
192+ } else {
193+ _, _ = term.Out().Write([]byte(statusReconnect))
194+ _ = term.Flush()
195+ showingStatus = true
196+ }
197+ }
176198 clearStatus := func() {
177199 if !showingStatus {
178200 return
179201 }
180- _, _ = term.Out().Write([]byte(statusClear))
202+ if remote {
203+ _ = clearReconnectToast(term.Out(), reconnectToastSize)
204+ } else {
205+ _, _ = term.Out().Write([]byte(statusClear))
206+ }
181207 _ = term.Flush()
182208 showingStatus = false
183209 }
210+ sleepWhileReconnecting := func(d time.Duration) bool {
211+ if remote && showingStatus {
212+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
213+ }
214+ return reconnectSleep(ctx, clk, d)
215+ }
184216
185217 for {
186218 transport, err := dialer.Dial(ctx)
187219 if err != nil {
188220 if resumeToken == 0 || ctx.Err() != nil {
221+ clearStatus()
189222 return err
190223 }
191224 log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
192225 drawStatus()
193- if !reconnectSleep(ctx, backoff) {
226+ if !sleepWhileReconnecting(backoff) {
227+ clearStatus()
194228 return ctx.Err()
195229 }
196230 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
@@ -217,7 +251,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
217251 }
218252 log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
219253 drawStatus()
220- if !reconnectSleep(ctx, backoff) {
254+ if !sleepWhileReconnecting(backoff) {
221255 clearStatus()
222256 return ctx.Err()
223257 }
@@ -226,17 +260,38 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
226260 }
227261 }
228262
229-func sleepReconnect(ctx context.Context, d time.Duration) bool {
230- t := time.NewTimer(d)
263+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
264+ t := clk.NewTimer(d)
231265 defer t.Stop()
232266 select {
233- case <-t.C:
267+ case <-t.C():
234268 return true
235269 case <-ctx.Done():
236270 return false
237271 }
238272 }
239273
274+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
275+ t := clk.NewTimer(d)
276+ defer t.Stop()
277+ for {
278+ select {
279+ case <-t.C():
280+ return true
281+ case <-ctx.Done():
282+ return false
283+ case size, ok := <-resizeEvents:
284+ if !ok {
285+ resizeEvents = nil
286+ continue
287+ }
288+ if onResize != nil {
289+ onResize(size)
290+ }
291+ }
292+ }
293+}
294+
240295 func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
241296 cur *= 2
242297 if cur > limit {
@@ -110,6 +110,7 @@ type reconnectBackoff struct {
110 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}110 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
111 111
112 var reconnectSleep = sleepReconnect112 var reconnectSleep = sleepReconnect
113+var reconnectSleepWithResize = sleepReconnectWithResizeEvents
113 114
114 const (115 const (
115 statusReconnect = "\r\x1b[2Kreconnecting…"116 statusReconnect = "\r\x1b[2Kreconnecting…"
@@ -165,32 +166,65 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
165 attemptIntent := intent166 attemptIntent := intent
166 backoff := defaultReconnectBackoff.initial167 backoff := defaultReconnectBackoff.initial
167 showingStatus := false168 showingStatus := false
168- drawStatus := func() {169+ var reconnectToastSize domain.Size
169- if !rawEntered || showingStatus {170+ redrawRemoteStatus := func(size domain.Size) {
171+ if !rawEntered || !remote {
170 return172 return
171 }173 }
172- _, _ = term.Out().Write([]byte(statusReconnect))174+ if showingStatus {
175+ _ = clearReconnectToast(term.Out(), reconnectToastSize)
176+ }
177+ _ = drawReconnectToast(term.Out(), size)
178+ reconnectToastSize = size
173 _ = term.Flush()179 _ = term.Flush()
174 showingStatus = true180 showingStatus = true
175 }181 }
182+ drawStatus := func() {
183+ if !rawEntered || showingStatus {
184+ return
185+ }
186+ if remote {
187+ size, err := term.Size()
188+ if err != nil {
189+ return
190+ }
191+ redrawRemoteStatus(size)
192+ } else {
193+ _, _ = term.Out().Write([]byte(statusReconnect))
194+ _ = term.Flush()
195+ showingStatus = true
196+ }
197+ }
176 clearStatus := func() {198 clearStatus := func() {
177 if !showingStatus {199 if !showingStatus {
178 return200 return
179 }201 }
180- _, _ = term.Out().Write([]byte(statusClear))202+ if remote {
203+ _ = clearReconnectToast(term.Out(), reconnectToastSize)
204+ } else {
205+ _, _ = term.Out().Write([]byte(statusClear))
206+ }
181 _ = term.Flush()207 _ = term.Flush()
182 showingStatus = false208 showingStatus = false
183 }209 }
210+ sleepWhileReconnecting := func(d time.Duration) bool {
211+ if remote && showingStatus {
212+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
213+ }
214+ return reconnectSleep(ctx, clk, d)
215+ }
184 216
185 for {217 for {
186 transport, err := dialer.Dial(ctx)218 transport, err := dialer.Dial(ctx)
187 if err != nil {219 if err != nil {
188 if resumeToken == 0 || ctx.Err() != nil {220 if resumeToken == 0 || ctx.Err() != nil {
221+ clearStatus()
189 return err222 return err
190 }223 }
191 log.Warn("reconnect dial failed", "err", err, "backoff", backoff)224 log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
192 drawStatus()225 drawStatus()
193- if !reconnectSleep(ctx, backoff) {226+ if !sleepWhileReconnecting(backoff) {
227+ clearStatus()
194 return ctx.Err()228 return ctx.Err()
195 }229 }
196 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)230 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
@@ -217,7 +251,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
217 }251 }
218 log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)252 log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
219 drawStatus()253 drawStatus()
220- if !reconnectSleep(ctx, backoff) {254+ if !sleepWhileReconnecting(backoff) {
221 clearStatus()255 clearStatus()
222 return ctx.Err()256 return ctx.Err()
223 }257 }
@@ -226,17 +260,38 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
226 }260 }
227 }261 }
228 262
229-func sleepReconnect(ctx context.Context, d time.Duration) bool {263+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
230- t := time.NewTimer(d)264+ t := clk.NewTimer(d)
231 defer t.Stop()265 defer t.Stop()
232 select {266 select {
233- case <-t.C:267+ case <-t.C():
234 return true268 return true
235 case <-ctx.Done():269 case <-ctx.Done():
236 return false270 return false
237 }271 }
238 }272 }
239 273
274+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
275+ t := clk.NewTimer(d)
276+ defer t.Stop()
277+ for {
278+ select {
279+ case <-t.C():
280+ return true
281+ case <-ctx.Done():
282+ return false
283+ case size, ok := <-resizeEvents:
284+ if !ok {
285+ resizeEvents = nil
286+ continue
287+ }
288+ if onResize != nil {
289+ onResize(size)
290+ }
291+ }
292+ }
293+}
294+
240 func nextReconnectBackoff(cur, limit time.Duration) time.Duration {295 func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
241 cur *= 2296 cur *= 2
242 if cur > limit {297 if cur > limit {
A internal/usecase/client/reconnect_toast.go +77 -0
@@ -0,0 +1,77 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const reconnectToastMessage = "Reconnection attempts…"
14+
15+func drawReconnectToast(out io.Writer, size domain.Size) error {
16+ bounds := reconnectToastBounds(size)
17+ if bounds.Width <= 0 || bounds.Height <= 0 {
18+ return nil
19+ }
20+ return writeReconnectToast(out, bounds, reconnectToastLines(bounds))
21+}
22+
23+func clearReconnectToast(out io.Writer, size domain.Size) error {
24+ bounds := reconnectToastBounds(size)
25+ if bounds.Width <= 0 || bounds.Height <= 0 {
26+ return nil
27+ }
28+ blank := strings.Repeat(" ", bounds.Width)
29+ lines := make([]string, bounds.Height)
30+ for i := range lines {
31+ lines[i] = blank
32+ }
33+ return writeReconnectToast(out, bounds, lines)
34+}
35+
36+func reconnectToastBounds(size domain.Size) domain.Rect {
37+ return ui.ToastBounds(size, ui.Toast{Message: reconnectToastMessage, Anchor: ui.ToastCenter})
38+}
39+
40+func reconnectToastLines(bounds domain.Rect) []string {
41+ if bounds.Width <= 0 || bounds.Height <= 0 {
42+ return nil
43+ }
44+ frame := renderer.NewFrame(bounds.Width, bounds.Height)
45+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: reconnectToastMessage, Anchor: ui.ToastCenter}}}, ui.ToastStyles{
46+ Text: renderer.DefaultStyle(),
47+ Box: renderer.DefaultStyle(),
48+ }, nil)
49+ lines := make([]string, bounds.Height)
50+ for y := range bounds.Height {
51+ var b strings.Builder
52+ b.Grow(bounds.Width)
53+ for x := range bounds.Width {
54+ cell := frame.At(x, y)
55+ if cell.Continuation || cell.Rune == 0 {
56+ b.WriteRune(' ')
57+ continue
58+ }
59+ b.WriteRune(cell.Rune)
60+ }
61+ lines[y] = b.String()
62+ }
63+ return lines
64+}
65+
66+func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {
67+ if _, err := io.WriteString(out, "\x1b[s"); err != nil {
68+ return err
69+ }
70+ for i, line := range lines {
71+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
72+ return err
73+ }
74+ }
75+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
76+ return err
77+}
@@ -0,0 +1,77 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const reconnectToastMessage = "Reconnection attempts…"
14+
15+func drawReconnectToast(out io.Writer, size domain.Size) error {
16+ bounds := reconnectToastBounds(size)
17+ if bounds.Width <= 0 || bounds.Height <= 0 {
18+ return nil
19+ }
20+ return writeReconnectToast(out, bounds, reconnectToastLines(bounds))
21+}
22+
23+func clearReconnectToast(out io.Writer, size domain.Size) error {
24+ bounds := reconnectToastBounds(size)
25+ if bounds.Width <= 0 || bounds.Height <= 0 {
26+ return nil
27+ }
28+ blank := strings.Repeat(" ", bounds.Width)
29+ lines := make([]string, bounds.Height)
30+ for i := range lines {
31+ lines[i] = blank
32+ }
33+ return writeReconnectToast(out, bounds, lines)
34+}
35+
36+func reconnectToastBounds(size domain.Size) domain.Rect {
37+ return ui.ToastBounds(size, ui.Toast{Message: reconnectToastMessage, Anchor: ui.ToastCenter})
38+}
39+
40+func reconnectToastLines(bounds domain.Rect) []string {
41+ if bounds.Width <= 0 || bounds.Height <= 0 {
42+ return nil
43+ }
44+ frame := renderer.NewFrame(bounds.Width, bounds.Height)
45+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: reconnectToastMessage, Anchor: ui.ToastCenter}}}, ui.ToastStyles{
46+ Text: renderer.DefaultStyle(),
47+ Box: renderer.DefaultStyle(),
48+ }, nil)
49+ lines := make([]string, bounds.Height)
50+ for y := range bounds.Height {
51+ var b strings.Builder
52+ b.Grow(bounds.Width)
53+ for x := range bounds.Width {
54+ cell := frame.At(x, y)
55+ if cell.Continuation || cell.Rune == 0 {
56+ b.WriteRune(' ')
57+ continue
58+ }
59+ b.WriteRune(cell.Rune)
60+ }
61+ lines[y] = b.String()
62+ }
63+ return lines
64+}
65+
66+func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {
67+ if _, err := io.WriteString(out, "\x1b[s"); err != nil {
68+ return err
69+ }
70+ for i, line := range lines {
71+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
72+ return err
73+ }
74+ }
75+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
76+ return err
77+}
A internal/usecase/client/reconnect_toast_test.go +213 -0
@@ -0,0 +1,213 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ "github.com/bnema/vev/pkg/renderer"
20+)
21+
22+type reconnectToastTerminalHarness struct {
23+ term *portsmocks.MockTerminal
24+ in *io.PipeReader
25+ inWriter *io.PipeWriter
26+ out bytes.Buffer
27+ size domain.Size
28+ resizeCh chan domain.Size
29+}
30+
31+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
32+ t.Helper()
33+ in, inWriter := io.Pipe()
34+ h := &reconnectToastTerminalHarness{
35+ term: portsmocks.NewMockTerminal(t),
36+ in: in,
37+ inWriter: inWriter,
38+ size: domain.Size{Cols: 80, Rows: 24},
39+ resizeCh: make(chan domain.Size),
40+ }
41+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
42+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
43+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
44+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
45+ h.term.EXPECT().In().Return(h.in).Maybe()
46+ h.term.EXPECT().Out().Return(&h.out).Maybe()
47+ h.term.EXPECT().Flush().Return(nil).Maybe()
48+ return h
49+}
50+
51+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
52+
53+func reconnectToastWelcome(token uint64) ports.Frame {
54+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
55+}
56+
57+func reconnectToastDetach(reason uint8) ports.Frame {
58+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
59+}
60+
61+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
62+ t.Helper()
63+ tr := portsmocks.NewMockTransport(t)
64+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
65+ for _, recv := range recvs {
66+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
67+ }
68+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
69+ tr.EXPECT().Close().Return(nil).Maybe()
70+ return tr
71+}
72+
73+type reconnectToastRecv struct {
74+ frame ports.Frame
75+ err error
76+}
77+
78+func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79+ var out bytes.Buffer
80+ size := domain.Size{Cols: 80, Rows: 24}
81+
82+ require.NoError(t, drawReconnectToast(&out, size))
83+ require.Contains(t, out.String(), "┌")
84+ require.Contains(t, out.String(), reconnectToastMessage)
85+ require.Contains(t, out.String(), "\x1b[0m")
86+
87+ require.NoError(t, clearReconnectToast(&out, size))
88+ bounds := reconnectToastBounds(size)
89+ require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
90+}
91+
92+func TestReconnectToastLinesClampToBounds(t *testing.T) {
93+ bounds := reconnectToastBounds(domain.Size{Cols: 8, Rows: 3})
94+ lines := reconnectToastLines(bounds)
95+ require.Len(t, lines, bounds.Height)
96+ for _, line := range lines {
97+ require.LessOrEqual(t, displayWidth(line), bounds.Width)
98+ }
99+ require.Contains(t, strings.Join(lines, "\n"), "…")
100+}
101+
102+func displayWidth(s string) int {
103+ width := 0
104+ for _, r := range s {
105+ width += renderer.RuneWidth(r)
106+ }
107+ return width
108+}
109+
110+func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
111+ clk := portsmocks.NewMockClock(t)
112+ timer := portsmocks.NewMockTimer(t)
113+ timerCh := make(chan time.Time, 1)
114+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
115+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
116+ timer.EXPECT().Stop().Return(true).Once()
117+ resizeCh := make(chan domain.Size, 2)
118+ resizeCh <- domain.Size{Cols: 100, Rows: 30}
119+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
120+ got := make(chan domain.Size, 2)
121+ done := make(chan bool, 1)
122+
123+ go func() {
124+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
125+ got <- size
126+ })
127+ }()
128+
129+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
130+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
131+ timerCh <- time.Time{}
132+ require.True(t, <-done)
133+}
134+
135+func TestRemoteReconnectToastLifecycle(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
139+ sleep func(cancel context.CancelFunc) bool
140+ wantErr func(t *testing.T, err error)
141+ wantBlank bool
142+ }{
143+ {
144+ name: "clears on successful reconnect",
145+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
146+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
147+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
148+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
149+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
150+ },
151+ sleep: func(context.CancelFunc) bool { return true },
152+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
153+ wantBlank: true,
154+ },
155+ {
156+ name: "clears on cancellation",
157+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
158+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
159+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
160+ },
161+ sleep: func(cancel context.CancelFunc) bool {
162+ cancel()
163+ return false
164+ },
165+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
166+ wantBlank: true,
167+ },
168+ {
169+ name: "clears on final exit",
170+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
171+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
172+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
173+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
174+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
175+ },
176+ sleep: func(context.CancelFunc) bool { return true },
177+ wantErr: func(t *testing.T, err error) {
178+ var detached *DetachedError
179+ require.True(t, errors.As(err, &detached))
180+ },
181+ wantBlank: true,
182+ },
183+ }
184+
185+ for _, tt := range tests {
186+ t.Run(tt.name, func(t *testing.T) {
187+ oldSleep := reconnectSleep
188+ oldSleepWithResize := reconnectSleepWithResize
189+ ctx, cancel := context.WithCancel(context.Background())
190+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
191+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
192+ return tt.sleep(cancel)
193+ }
194+ defer func() {
195+ reconnectSleep = oldSleep
196+ reconnectSleepWithResize = oldSleepWithResize
197+ }()
198+
199+ term := newReconnectToastTerminalHarness(t)
200+ defer term.closeInput()
201+ dialer := portsmocks.NewMockDialer(t)
202+ tt.configure(t, dialer)
203+
204+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
205+ tt.wantErr(t, err)
206+ out := term.out.String()
207+ require.Contains(t, out, reconnectToastMessage)
208+ if tt.wantBlank {
209+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
210+ }
211+ })
212+ }
213+}
@@ -0,0 +1,213 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ "github.com/bnema/vev/pkg/renderer"
20+)
21+
22+type reconnectToastTerminalHarness struct {
23+ term *portsmocks.MockTerminal
24+ in *io.PipeReader
25+ inWriter *io.PipeWriter
26+ out bytes.Buffer
27+ size domain.Size
28+ resizeCh chan domain.Size
29+}
30+
31+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
32+ t.Helper()
33+ in, inWriter := io.Pipe()
34+ h := &reconnectToastTerminalHarness{
35+ term: portsmocks.NewMockTerminal(t),
36+ in: in,
37+ inWriter: inWriter,
38+ size: domain.Size{Cols: 80, Rows: 24},
39+ resizeCh: make(chan domain.Size),
40+ }
41+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
42+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
43+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
44+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
45+ h.term.EXPECT().In().Return(h.in).Maybe()
46+ h.term.EXPECT().Out().Return(&h.out).Maybe()
47+ h.term.EXPECT().Flush().Return(nil).Maybe()
48+ return h
49+}
50+
51+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
52+
53+func reconnectToastWelcome(token uint64) ports.Frame {
54+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
55+}
56+
57+func reconnectToastDetach(reason uint8) ports.Frame {
58+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
59+}
60+
61+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
62+ t.Helper()
63+ tr := portsmocks.NewMockTransport(t)
64+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
65+ for _, recv := range recvs {
66+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
67+ }
68+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
69+ tr.EXPECT().Close().Return(nil).Maybe()
70+ return tr
71+}
72+
73+type reconnectToastRecv struct {
74+ frame ports.Frame
75+ err error
76+}
77+
78+func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79+ var out bytes.Buffer
80+ size := domain.Size{Cols: 80, Rows: 24}
81+
82+ require.NoError(t, drawReconnectToast(&out, size))
83+ require.Contains(t, out.String(), "┌")
84+ require.Contains(t, out.String(), reconnectToastMessage)
85+ require.Contains(t, out.String(), "\x1b[0m")
86+
87+ require.NoError(t, clearReconnectToast(&out, size))
88+ bounds := reconnectToastBounds(size)
89+ require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
90+}
91+
92+func TestReconnectToastLinesClampToBounds(t *testing.T) {
93+ bounds := reconnectToastBounds(domain.Size{Cols: 8, Rows: 3})
94+ lines := reconnectToastLines(bounds)
95+ require.Len(t, lines, bounds.Height)
96+ for _, line := range lines {
97+ require.LessOrEqual(t, displayWidth(line), bounds.Width)
98+ }
99+ require.Contains(t, strings.Join(lines, "\n"), "…")
100+}
101+
102+func displayWidth(s string) int {
103+ width := 0
104+ for _, r := range s {
105+ width += renderer.RuneWidth(r)
106+ }
107+ return width
108+}
109+
110+func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
111+ clk := portsmocks.NewMockClock(t)
112+ timer := portsmocks.NewMockTimer(t)
113+ timerCh := make(chan time.Time, 1)
114+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
115+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
116+ timer.EXPECT().Stop().Return(true).Once()
117+ resizeCh := make(chan domain.Size, 2)
118+ resizeCh <- domain.Size{Cols: 100, Rows: 30}
119+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
120+ got := make(chan domain.Size, 2)
121+ done := make(chan bool, 1)
122+
123+ go func() {
124+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
125+ got <- size
126+ })
127+ }()
128+
129+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
130+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
131+ timerCh <- time.Time{}
132+ require.True(t, <-done)
133+}
134+
135+func TestRemoteReconnectToastLifecycle(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
139+ sleep func(cancel context.CancelFunc) bool
140+ wantErr func(t *testing.T, err error)
141+ wantBlank bool
142+ }{
143+ {
144+ name: "clears on successful reconnect",
145+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
146+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
147+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
148+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
149+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
150+ },
151+ sleep: func(context.CancelFunc) bool { return true },
152+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
153+ wantBlank: true,
154+ },
155+ {
156+ name: "clears on cancellation",
157+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
158+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
159+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
160+ },
161+ sleep: func(cancel context.CancelFunc) bool {
162+ cancel()
163+ return false
164+ },
165+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
166+ wantBlank: true,
167+ },
168+ {
169+ name: "clears on final exit",
170+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
171+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
172+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
173+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
174+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
175+ },
176+ sleep: func(context.CancelFunc) bool { return true },
177+ wantErr: func(t *testing.T, err error) {
178+ var detached *DetachedError
179+ require.True(t, errors.As(err, &detached))
180+ },
181+ wantBlank: true,
182+ },
183+ }
184+
185+ for _, tt := range tests {
186+ t.Run(tt.name, func(t *testing.T) {
187+ oldSleep := reconnectSleep
188+ oldSleepWithResize := reconnectSleepWithResize
189+ ctx, cancel := context.WithCancel(context.Background())
190+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
191+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
192+ return tt.sleep(cancel)
193+ }
194+ defer func() {
195+ reconnectSleep = oldSleep
196+ reconnectSleepWithResize = oldSleepWithResize
197+ }()
198+
199+ term := newReconnectToastTerminalHarness(t)
200+ defer term.closeInput()
201+ dialer := portsmocks.NewMockDialer(t)
202+ tt.configure(t, dialer)
203+
204+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
205+ tt.wantErr(t, err)
206+ out := term.out.String()
207+ require.Contains(t, out, reconnectToastMessage)
208+ if tt.wantBlank {
209+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
210+ }
211+ })
212+ }
213+}
A internal/usecase/ui/toast.go +275 -0
@@ -0,0 +1,275 @@
1+package ui
2+
3+import (
4+ "strconv"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const (
12+ defaultToastMargin = 2
13+ defaultToastPaddingX = 1
14+ defaultToastMaxWidth = 60
15+)
16+
17+// ToastAnchor describes where a toast is positioned within a frame.
18+type ToastAnchor int
19+
20+const (
21+ ToastTopLeft ToastAnchor = iota
22+ ToastTopRight
23+ ToastBottomLeft
24+ ToastBottomRight
25+ ToastCenter
26+)
27+
28+// Toast describes a transient message rendered over a frame.
29+type Toast struct {
30+ ID string
31+ Message string
32+ Anchor ToastAnchor
33+ Duration time.Duration
34+ DimBackground bool
35+ MinWidth int
36+ MaxWidth int
37+ PaddingX int
38+ PaddingY int
39+}
40+
41+// ActiveToast is a toast currently tracked by a manager.
42+type ActiveToast struct {
43+ Toast
44+ ShownAt time.Time
45+}
46+
47+// ToastManager tracks currently visible toasts.
48+type ToastManager struct {
49+ active map[string]ActiveToast
50+}
51+
52+// ToastStyles contains styles used when drawing a toast.
53+type ToastStyles struct {
54+ Text renderer.Style
55+ Box renderer.Style
56+}
57+
58+// DimStyleFunc transforms an existing cell style for dimmed backgrounds.
59+type DimStyleFunc func(renderer.Style) renderer.Style
60+
61+// NewToastManager creates an empty toast manager.
62+func NewToastManager() *ToastManager {
63+ return &ToastManager{active: make(map[string]ActiveToast)}
64+}
65+
66+// Show records toast as active at now. Empty IDs are replaced with a stable key.
67+func (m *ToastManager) Show(now time.Time, toast Toast) {
68+ if m.active == nil {
69+ m.active = make(map[string]ActiveToast)
70+ }
71+ id := toast.ID
72+ if id == "" {
73+ id = toastAnchorID(toast.Anchor)
74+ toast.ID = id
75+ }
76+ m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
77+}
78+
79+func toastAnchorID(anchor ToastAnchor) string {
80+ return "anchor:" + strconv.Itoa(int(anchor))
81+}
82+
83+// Dismiss removes a toast by ID.
84+func (m *ToastManager) Dismiss(id string) {
85+ delete(m.active, id)
86+}
87+
88+// Clear removes all toasts.
89+func (m *ToastManager) Clear() {
90+ m.active = make(map[string]ActiveToast)
91+}
92+
93+// Active returns non-expired toasts at now and prunes expired entries.
94+func (m *ToastManager) Active(now time.Time) []ActiveToast {
95+ if m.active == nil {
96+ return nil
97+ }
98+ active := make([]ActiveToast, 0, len(m.active))
99+ for id, toast := range m.active {
100+ if toast.Duration > 0 && !now.Before(toast.ShownAt.Add(toast.Duration)) {
101+ delete(m.active, id)
102+ continue
103+ }
104+ active = append(active, toast)
105+ }
106+ return active
107+}
108+
109+// HasActive reports whether any toast is active at now.
110+func (m *ToastManager) HasActive(now time.Time) bool {
111+ return len(m.Active(now)) > 0
112+}
113+
114+// ToastBounds returns the toast rectangle positioned within base and clamped to base.
115+func ToastBounds(base domain.Size, toast Toast) domain.Rect {
116+ if base.Cols <= 0 || base.Rows <= 0 {
117+ return domain.Rect{}
118+ }
119+
120+ paddingX := toast.PaddingX
121+ if paddingX == 0 {
122+ paddingX = defaultToastPaddingX
123+ }
124+ const borderWidth = 2
125+ const borderHeight = 2
126+
127+ maxWidth := toast.MaxWidth
128+ if maxWidth <= 0 {
129+ maxWidth = defaultToastMaxWidth
130+ }
131+ width := textWidth(toast.Message) + paddingX*2 + borderWidth
132+ width = max(width, toast.MinWidth)
133+ width = clamp(width, 0, maxWidth)
134+ width = clamp(width, 0, base.Cols)
135+
136+ height := 1 + toast.PaddingY*2 + borderHeight
137+ height = clamp(height, 0, base.Rows)
138+
139+ x := 0
140+ y := 0
141+ switch toast.Anchor {
142+ case ToastTopLeft:
143+ x = defaultToastMargin
144+ y = defaultToastMargin
145+ case ToastTopRight:
146+ x = base.Cols - defaultToastMargin - width
147+ y = defaultToastMargin
148+ case ToastBottomLeft:
149+ x = defaultToastMargin
150+ y = base.Rows - defaultToastMargin - height
151+ case ToastBottomRight:
152+ x = base.Cols - defaultToastMargin - width
153+ y = base.Rows - defaultToastMargin - height
154+ case ToastCenter:
155+ x = (base.Cols - width) / 2
156+ y = (base.Rows - height) / 2
157+ }
158+
159+ return domain.Rect{
160+ X: clamp(x, 0, max(0, base.Cols-width)),
161+ Y: clamp(y, 0, max(0, base.Rows-height)),
162+ Width: width,
163+ Height: height,
164+ }
165+}
166+
167+// CompositeToasts draws the latest toast per anchor over frame.
168+func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastStyles, dim DimStyleFunc) {
169+ visible := latestToastsByAnchor(toasts)
170+ if len(visible) == 0 {
171+ return
172+ }
173+
174+ if dim != nil {
175+ shouldDim := false
176+ for _, toast := range visible {
177+ if toast.DimBackground {
178+ shouldDim = true
179+ break
180+ }
181+ }
182+ if shouldDim {
183+ for y := 0; y < frame.Height; y++ {
184+ for x := 0; x < frame.Width; x++ {
185+ cell := frame.At(x, y)
186+ cell.Style = dim(cell.Style)
187+ frame.Set(x, y, cell)
188+ }
189+ }
190+ }
191+ }
192+
193+ for _, toast := range visible {
194+ drawToast(frame, toast.Toast, styles)
195+ }
196+}
197+
198+func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
199+ byAnchor := make(map[ToastAnchor]ActiveToast)
200+ for _, toast := range toasts {
201+ current, ok := byAnchor[toast.Anchor]
202+ if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
203+ byAnchor[toast.Anchor] = toast
204+ }
205+ }
206+ anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter}
207+ visible := make([]ActiveToast, 0, len(byAnchor))
208+ for _, anchor := range anchors {
209+ if toast, ok := byAnchor[anchor]; ok {
210+ visible = append(visible, toast)
211+ }
212+ }
213+ return visible
214+}
215+
216+func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) {
217+ bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast)
218+ if bounds.Width <= 0 || bounds.Height <= 0 {
219+ return
220+ }
221+
222+ DrawBox(frame, bounds, styles.Box)
223+ inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
224+ if inner.Width <= 0 || inner.Height <= 0 {
225+ return
226+ }
227+
228+ fill := renderer.BlankCell()
229+ fill.Style = styles.Text
230+ FillRect(frame, inner, fill)
231+
232+ paddingX := toast.PaddingX
233+ if paddingX == 0 {
234+ paddingX = defaultToastPaddingX
235+ }
236+ textY := inner.Y + toast.PaddingY
237+ if textY >= inner.Y+inner.Height {
238+ return
239+ }
240+ textLeft := inner.X + paddingX
241+ textRight := inner.X + inner.Width - paddingX
242+ if textRight <= textLeft {
243+ return
244+ }
245+ DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text)
246+}
247+
248+func truncateText(text string, maxWidth int) string {
249+ if maxWidth <= 0 {
250+ return ""
251+ }
252+ if textWidth(text) <= maxWidth {
253+ return text
254+ }
255+ ellipsis := "…"
256+ ellipsisWidth := textWidth(ellipsis)
257+ if maxWidth < ellipsisWidth {
258+ return ""
259+ }
260+ limit := maxWidth - ellipsisWidth
261+ out := make([]rune, 0, len(text))
262+ width := 0
263+ for _, r := range text {
264+ w := renderer.RuneWidth(r)
265+ if w == 0 {
266+ continue
267+ }
268+ if width+w > limit {
269+ break
270+ }
271+ out = append(out, r)
272+ width += w
273+ }
274+ return string(out) + ellipsis
275+}
@@ -0,0 +1,275 @@
1+package ui
2+
3+import (
4+ "strconv"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const (
12+ defaultToastMargin = 2
13+ defaultToastPaddingX = 1
14+ defaultToastMaxWidth = 60
15+)
16+
17+// ToastAnchor describes where a toast is positioned within a frame.
18+type ToastAnchor int
19+
20+const (
21+ ToastTopLeft ToastAnchor = iota
22+ ToastTopRight
23+ ToastBottomLeft
24+ ToastBottomRight
25+ ToastCenter
26+)
27+
28+// Toast describes a transient message rendered over a frame.
29+type Toast struct {
30+ ID string
31+ Message string
32+ Anchor ToastAnchor
33+ Duration time.Duration
34+ DimBackground bool
35+ MinWidth int
36+ MaxWidth int
37+ PaddingX int
38+ PaddingY int
39+}
40+
41+// ActiveToast is a toast currently tracked by a manager.
42+type ActiveToast struct {
43+ Toast
44+ ShownAt time.Time
45+}
46+
47+// ToastManager tracks currently visible toasts.
48+type ToastManager struct {
49+ active map[string]ActiveToast
50+}
51+
52+// ToastStyles contains styles used when drawing a toast.
53+type ToastStyles struct {
54+ Text renderer.Style
55+ Box renderer.Style
56+}
57+
58+// DimStyleFunc transforms an existing cell style for dimmed backgrounds.
59+type DimStyleFunc func(renderer.Style) renderer.Style
60+
61+// NewToastManager creates an empty toast manager.
62+func NewToastManager() *ToastManager {
63+ return &ToastManager{active: make(map[string]ActiveToast)}
64+}
65+
66+// Show records toast as active at now. Empty IDs are replaced with a stable key.
67+func (m *ToastManager) Show(now time.Time, toast Toast) {
68+ if m.active == nil {
69+ m.active = make(map[string]ActiveToast)
70+ }
71+ id := toast.ID
72+ if id == "" {
73+ id = toastAnchorID(toast.Anchor)
74+ toast.ID = id
75+ }
76+ m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
77+}
78+
79+func toastAnchorID(anchor ToastAnchor) string {
80+ return "anchor:" + strconv.Itoa(int(anchor))
81+}
82+
83+// Dismiss removes a toast by ID.
84+func (m *ToastManager) Dismiss(id string) {
85+ delete(m.active, id)
86+}
87+
88+// Clear removes all toasts.
89+func (m *ToastManager) Clear() {
90+ m.active = make(map[string]ActiveToast)
91+}
92+
93+// Active returns non-expired toasts at now and prunes expired entries.
94+func (m *ToastManager) Active(now time.Time) []ActiveToast {
95+ if m.active == nil {
96+ return nil
97+ }
98+ active := make([]ActiveToast, 0, len(m.active))
99+ for id, toast := range m.active {
100+ if toast.Duration > 0 && !now.Before(toast.ShownAt.Add(toast.Duration)) {
101+ delete(m.active, id)
102+ continue
103+ }
104+ active = append(active, toast)
105+ }
106+ return active
107+}
108+
109+// HasActive reports whether any toast is active at now.
110+func (m *ToastManager) HasActive(now time.Time) bool {
111+ return len(m.Active(now)) > 0
112+}
113+
114+// ToastBounds returns the toast rectangle positioned within base and clamped to base.
115+func ToastBounds(base domain.Size, toast Toast) domain.Rect {
116+ if base.Cols <= 0 || base.Rows <= 0 {
117+ return domain.Rect{}
118+ }
119+
120+ paddingX := toast.PaddingX
121+ if paddingX == 0 {
122+ paddingX = defaultToastPaddingX
123+ }
124+ const borderWidth = 2
125+ const borderHeight = 2
126+
127+ maxWidth := toast.MaxWidth
128+ if maxWidth <= 0 {
129+ maxWidth = defaultToastMaxWidth
130+ }
131+ width := textWidth(toast.Message) + paddingX*2 + borderWidth
132+ width = max(width, toast.MinWidth)
133+ width = clamp(width, 0, maxWidth)
134+ width = clamp(width, 0, base.Cols)
135+
136+ height := 1 + toast.PaddingY*2 + borderHeight
137+ height = clamp(height, 0, base.Rows)
138+
139+ x := 0
140+ y := 0
141+ switch toast.Anchor {
142+ case ToastTopLeft:
143+ x = defaultToastMargin
144+ y = defaultToastMargin
145+ case ToastTopRight:
146+ x = base.Cols - defaultToastMargin - width
147+ y = defaultToastMargin
148+ case ToastBottomLeft:
149+ x = defaultToastMargin
150+ y = base.Rows - defaultToastMargin - height
151+ case ToastBottomRight:
152+ x = base.Cols - defaultToastMargin - width
153+ y = base.Rows - defaultToastMargin - height
154+ case ToastCenter:
155+ x = (base.Cols - width) / 2
156+ y = (base.Rows - height) / 2
157+ }
158+
159+ return domain.Rect{
160+ X: clamp(x, 0, max(0, base.Cols-width)),
161+ Y: clamp(y, 0, max(0, base.Rows-height)),
162+ Width: width,
163+ Height: height,
164+ }
165+}
166+
167+// CompositeToasts draws the latest toast per anchor over frame.
168+func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastStyles, dim DimStyleFunc) {
169+ visible := latestToastsByAnchor(toasts)
170+ if len(visible) == 0 {
171+ return
172+ }
173+
174+ if dim != nil {
175+ shouldDim := false
176+ for _, toast := range visible {
177+ if toast.DimBackground {
178+ shouldDim = true
179+ break
180+ }
181+ }
182+ if shouldDim {
183+ for y := 0; y < frame.Height; y++ {
184+ for x := 0; x < frame.Width; x++ {
185+ cell := frame.At(x, y)
186+ cell.Style = dim(cell.Style)
187+ frame.Set(x, y, cell)
188+ }
189+ }
190+ }
191+ }
192+
193+ for _, toast := range visible {
194+ drawToast(frame, toast.Toast, styles)
195+ }
196+}
197+
198+func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
199+ byAnchor := make(map[ToastAnchor]ActiveToast)
200+ for _, toast := range toasts {
201+ current, ok := byAnchor[toast.Anchor]
202+ if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
203+ byAnchor[toast.Anchor] = toast
204+ }
205+ }
206+ anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter}
207+ visible := make([]ActiveToast, 0, len(byAnchor))
208+ for _, anchor := range anchors {
209+ if toast, ok := byAnchor[anchor]; ok {
210+ visible = append(visible, toast)
211+ }
212+ }
213+ return visible
214+}
215+
216+func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) {
217+ bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast)
218+ if bounds.Width <= 0 || bounds.Height <= 0 {
219+ return
220+ }
221+
222+ DrawBox(frame, bounds, styles.Box)
223+ inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
224+ if inner.Width <= 0 || inner.Height <= 0 {
225+ return
226+ }
227+
228+ fill := renderer.BlankCell()
229+ fill.Style = styles.Text
230+ FillRect(frame, inner, fill)
231+
232+ paddingX := toast.PaddingX
233+ if paddingX == 0 {
234+ paddingX = defaultToastPaddingX
235+ }
236+ textY := inner.Y + toast.PaddingY
237+ if textY >= inner.Y+inner.Height {
238+ return
239+ }
240+ textLeft := inner.X + paddingX
241+ textRight := inner.X + inner.Width - paddingX
242+ if textRight <= textLeft {
243+ return
244+ }
245+ DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text)
246+}
247+
248+func truncateText(text string, maxWidth int) string {
249+ if maxWidth <= 0 {
250+ return ""
251+ }
252+ if textWidth(text) <= maxWidth {
253+ return text
254+ }
255+ ellipsis := "…"
256+ ellipsisWidth := textWidth(ellipsis)
257+ if maxWidth < ellipsisWidth {
258+ return ""
259+ }
260+ limit := maxWidth - ellipsisWidth
261+ out := make([]rune, 0, len(text))
262+ width := 0
263+ for _, r := range text {
264+ w := renderer.RuneWidth(r)
265+ if w == 0 {
266+ continue
267+ }
268+ if width+w > limit {
269+ break
270+ }
271+ out = append(out, r)
272+ width += w
273+ }
274+ return string(out) + ellipsis
275+}
A internal/usecase/ui/toast_test.go +251 -0
@@ -0,0 +1,251 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
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 replace per anchor", func(t *testing.T) {
58+ m := NewToastManager()
59+ m.Show(now, Toast{Message: "first", Anchor: ToastCenter})
60+ m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: ToastCenter})
61+ active := m.Active(now.Add(time.Second))
62+ if len(active) != 1 || active[0].Message != "second" {
63+ t.Fatalf("Active() after same-anchor replacement = %+v, want only second toast", active)
64+ }
65+ })
66+
67+ t.Run("clear removes all toasts", func(t *testing.T) {
68+ m := NewToastManager()
69+ m.Show(now, Toast{ID: "one"})
70+ m.Show(now, Toast{ID: "two"})
71+ m.Clear()
72+ if got := m.Active(now); len(got) != 0 {
73+ t.Fatalf("Active() after Clear() = %+v, want none", got)
74+ }
75+ })
76+}
77+
78+func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
79+ message := "Reconnection attempts…"
80+ width := textWidth(message) + 4
81+ if width != 26 {
82+ t.Fatalf("test message width = %d, want 26", width)
83+ }
84+
85+ tests := []struct {
86+ name string
87+ base domain.Size
88+ make func() Toast
89+ want domain.Rect
90+ }{
91+ {
92+ name: "center",
93+ base: domain.Size{Cols: 80, Rows: 24},
94+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastCenter} },
95+ want: domain.Rect{X: 27, Y: 10, Width: 26, Height: 3},
96+ },
97+ {
98+ name: "top left has two cell margins",
99+ base: domain.Size{Cols: 80, Rows: 24},
100+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopLeft} },
101+ want: domain.Rect{X: 2, Y: 2, Width: 26, Height: 3},
102+ },
103+ {
104+ name: "top right has two cell margins",
105+ base: domain.Size{Cols: 80, Rows: 24},
106+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopRight} },
107+ want: domain.Rect{X: 52, Y: 2, Width: 26, Height: 3},
108+ },
109+ {
110+ name: "bottom left has two cell margins",
111+ base: domain.Size{Cols: 80, Rows: 24},
112+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomLeft} },
113+ want: domain.Rect{X: 2, Y: 19, Width: 26, Height: 3},
114+ },
115+ {
116+ name: "bottom right has two cell margins",
117+ base: domain.Size{Cols: 80, Rows: 24},
118+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomRight} },
119+ want: domain.Rect{X: 52, Y: 19, Width: 26, Height: 3},
120+ },
121+ {
122+ name: "zero frame",
123+ base: domain.Size{},
124+ make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
125+ want: domain.Rect{},
126+ },
127+ {
128+ name: "one by one clamps",
129+ base: domain.Size{Cols: 1, Rows: 1},
130+ make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
131+ want: domain.Rect{X: 0, Y: 0, Width: 1, Height: 1},
132+ },
133+ {
134+ name: "narrow frame clamps width and margin",
135+ base: domain.Size{Cols: 8, Rows: 2},
136+ make: func() Toast { return Toast{Message: "long message", Anchor: ToastBottomRight} },
137+ want: domain.Rect{X: 0, Y: 0, Width: 8, Height: 2},
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ if got := ToastBounds(tt.base, tt.make()); got != tt.want {
144+ t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
145+ }
146+ })
147+ }
148+}
149+
150+func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
151+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
152+
153+ t.Run("bordered centered toast preserves exterior", func(t *testing.T) {
154+ f := renderer.NewFrame(20, 7)
155+ exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
156+ FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
157+
158+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: ToastCenter}}}, styles, nil)
159+ bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: ToastCenter})
160+ if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
161+ t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
162+ }
163+ assertRune(t, f, bounds.X, bounds.Y, '┌')
164+ assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y, '┐')
165+ assertRune(t, f, bounds.X, bounds.Y+bounds.Height-1, '└')
166+ assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, '┘')
167+ assertRune(t, f, bounds.X+1, bounds.Y, '─')
168+ assertRune(t, f, bounds.X, bounds.Y+1, '│')
169+ assertRune(t, f, bounds.X+2, bounds.Y+1, 'H')
170+ assertRune(t, f, bounds.X+3, bounds.Y+1, 'i')
171+ assertCell(t, f, 0, 0, exterior)
172+ assertCell(t, f, 19, 6, exterior)
173+ })
174+
175+ t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
176+ f := renderer.NewFrame(8, 3)
177+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil)
178+ assertRune(t, f, 0, 0, '┌')
179+ assertRune(t, f, 7, 0, '┐')
180+ assertRune(t, f, 2, 1, 'a')
181+ assertRune(t, f, 3, 1, 'b')
182+ assertRune(t, f, 4, 1, 'c')
183+ assertRune(t, f, 5, 1, '…')
184+ })
185+}
186+
187+func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
188+ f := renderer.NewFrame(30, 5)
189+ now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
190+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
191+
192+ CompositeToasts(f, []ActiveToast{
193+ {Toast: Toast{ID: "old", Message: "Old", Anchor: ToastTopLeft}, ShownAt: now},
194+ {Toast: Toast{ID: "new", Message: "New", Anchor: ToastTopLeft}, ShownAt: now.Add(time.Second)},
195+ {Toast: Toast{ID: "center", Message: "Mid", Anchor: ToastCenter}, ShownAt: now},
196+ }, styles, nil)
197+
198+ assertRune(t, f, 4, 3, 'N')
199+ assertRune(t, f, 5, 3, 'e')
200+ assertRune(t, f, 6, 3, 'w')
201+ for y := 0; y < f.Height; y++ {
202+ for x := 0; x < f.Width; x++ {
203+ if f.At(x, y).Rune == 'O' {
204+ t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y)
205+ }
206+ }
207+ }
208+}
209+
210+func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
211+ f := renderer.NewFrame(14, 5)
212+ baseStyle := renderer.Style{Foreground: 1, Background: 2}
213+ dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true}
214+ for y := 0; y < f.Height; y++ {
215+ for x := 0; x < f.Width; x++ {
216+ f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle})
217+ }
218+ }
219+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
220+
221+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
222+ return dimStyle
223+ })
224+
225+ outside := f.At(0, 0)
226+ if outside.Rune != 'a' {
227+ t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune)
228+ }
229+ if !outside.Style.Equal(dimStyle) {
230+ t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
231+ }
232+ bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter})
233+ boxCell := f.At(bounds.X, bounds.Y)
234+ if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
235+ t.Fatalf("box cell = %+v, want toast box style", boxCell)
236+ }
237+ textCell := f.At(bounds.X+2, bounds.Y+1)
238+ if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) {
239+ t.Fatalf("text cell = %+v, want toast text style", textCell)
240+ }
241+}
242+
243+func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
244+ styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()}
245+ for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
246+ t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
247+ f := renderer.NewFrame(size.Cols, size.Rows)
248+ CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil)
249+ })
250+ }
251+}
@@ -0,0 +1,251 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
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 replace per anchor", func(t *testing.T) {
58+ m := NewToastManager()
59+ m.Show(now, Toast{Message: "first", Anchor: ToastCenter})
60+ m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: ToastCenter})
61+ active := m.Active(now.Add(time.Second))
62+ if len(active) != 1 || active[0].Message != "second" {
63+ t.Fatalf("Active() after same-anchor replacement = %+v, want only second toast", active)
64+ }
65+ })
66+
67+ t.Run("clear removes all toasts", func(t *testing.T) {
68+ m := NewToastManager()
69+ m.Show(now, Toast{ID: "one"})
70+ m.Show(now, Toast{ID: "two"})
71+ m.Clear()
72+ if got := m.Active(now); len(got) != 0 {
73+ t.Fatalf("Active() after Clear() = %+v, want none", got)
74+ }
75+ })
76+}
77+
78+func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
79+ message := "Reconnection attempts…"
80+ width := textWidth(message) + 4
81+ if width != 26 {
82+ t.Fatalf("test message width = %d, want 26", width)
83+ }
84+
85+ tests := []struct {
86+ name string
87+ base domain.Size
88+ make func() Toast
89+ want domain.Rect
90+ }{
91+ {
92+ name: "center",
93+ base: domain.Size{Cols: 80, Rows: 24},
94+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastCenter} },
95+ want: domain.Rect{X: 27, Y: 10, Width: 26, Height: 3},
96+ },
97+ {
98+ name: "top left has two cell margins",
99+ base: domain.Size{Cols: 80, Rows: 24},
100+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopLeft} },
101+ want: domain.Rect{X: 2, Y: 2, Width: 26, Height: 3},
102+ },
103+ {
104+ name: "top right has two cell margins",
105+ base: domain.Size{Cols: 80, Rows: 24},
106+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopRight} },
107+ want: domain.Rect{X: 52, Y: 2, Width: 26, Height: 3},
108+ },
109+ {
110+ name: "bottom left has two cell margins",
111+ base: domain.Size{Cols: 80, Rows: 24},
112+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomLeft} },
113+ want: domain.Rect{X: 2, Y: 19, Width: 26, Height: 3},
114+ },
115+ {
116+ name: "bottom right has two cell margins",
117+ base: domain.Size{Cols: 80, Rows: 24},
118+ make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastBottomRight} },
119+ want: domain.Rect{X: 52, Y: 19, Width: 26, Height: 3},
120+ },
121+ {
122+ name: "zero frame",
123+ base: domain.Size{},
124+ make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
125+ want: domain.Rect{},
126+ },
127+ {
128+ name: "one by one clamps",
129+ base: domain.Size{Cols: 1, Rows: 1},
130+ make: func() Toast { return Toast{Message: "tiny", Anchor: ToastCenter} },
131+ want: domain.Rect{X: 0, Y: 0, Width: 1, Height: 1},
132+ },
133+ {
134+ name: "narrow frame clamps width and margin",
135+ base: domain.Size{Cols: 8, Rows: 2},
136+ make: func() Toast { return Toast{Message: "long message", Anchor: ToastBottomRight} },
137+ want: domain.Rect{X: 0, Y: 0, Width: 8, Height: 2},
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ if got := ToastBounds(tt.base, tt.make()); got != tt.want {
144+ t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
145+ }
146+ })
147+ }
148+}
149+
150+func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
151+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
152+
153+ t.Run("bordered centered toast preserves exterior", func(t *testing.T) {
154+ f := renderer.NewFrame(20, 7)
155+ exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
156+ FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
157+
158+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: ToastCenter}}}, styles, nil)
159+ bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: ToastCenter})
160+ if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
161+ t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
162+ }
163+ assertRune(t, f, bounds.X, bounds.Y, '┌')
164+ assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y, '┐')
165+ assertRune(t, f, bounds.X, bounds.Y+bounds.Height-1, '└')
166+ assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, '┘')
167+ assertRune(t, f, bounds.X+1, bounds.Y, '─')
168+ assertRune(t, f, bounds.X, bounds.Y+1, '│')
169+ assertRune(t, f, bounds.X+2, bounds.Y+1, 'H')
170+ assertRune(t, f, bounds.X+3, bounds.Y+1, 'i')
171+ assertCell(t, f, 0, 0, exterior)
172+ assertCell(t, f, 19, 6, exterior)
173+ })
174+
175+ t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
176+ f := renderer.NewFrame(8, 3)
177+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil)
178+ assertRune(t, f, 0, 0, '┌')
179+ assertRune(t, f, 7, 0, '┐')
180+ assertRune(t, f, 2, 1, 'a')
181+ assertRune(t, f, 3, 1, 'b')
182+ assertRune(t, f, 4, 1, 'c')
183+ assertRune(t, f, 5, 1, '…')
184+ })
185+}
186+
187+func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
188+ f := renderer.NewFrame(30, 5)
189+ now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
190+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
191+
192+ CompositeToasts(f, []ActiveToast{
193+ {Toast: Toast{ID: "old", Message: "Old", Anchor: ToastTopLeft}, ShownAt: now},
194+ {Toast: Toast{ID: "new", Message: "New", Anchor: ToastTopLeft}, ShownAt: now.Add(time.Second)},
195+ {Toast: Toast{ID: "center", Message: "Mid", Anchor: ToastCenter}, ShownAt: now},
196+ }, styles, nil)
197+
198+ assertRune(t, f, 4, 3, 'N')
199+ assertRune(t, f, 5, 3, 'e')
200+ assertRune(t, f, 6, 3, 'w')
201+ for y := 0; y < f.Height; y++ {
202+ for x := 0; x < f.Width; x++ {
203+ if f.At(x, y).Rune == 'O' {
204+ t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y)
205+ }
206+ }
207+ }
208+}
209+
210+func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
211+ f := renderer.NewFrame(14, 5)
212+ baseStyle := renderer.Style{Foreground: 1, Background: 2}
213+ dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true}
214+ for y := 0; y < f.Height; y++ {
215+ for x := 0; x < f.Width; x++ {
216+ f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle})
217+ }
218+ }
219+ styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
220+
221+ CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
222+ return dimStyle
223+ })
224+
225+ outside := f.At(0, 0)
226+ if outside.Rune != 'a' {
227+ t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune)
228+ }
229+ if !outside.Style.Equal(dimStyle) {
230+ t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
231+ }
232+ bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter})
233+ boxCell := f.At(bounds.X, bounds.Y)
234+ if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
235+ t.Fatalf("box cell = %+v, want toast box style", boxCell)
236+ }
237+ textCell := f.At(bounds.X+2, bounds.Y+1)
238+ if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) {
239+ t.Fatalf("text cell = %+v, want toast text style", textCell)
240+ }
241+}
242+
243+func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
244+ styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()}
245+ for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
246+ t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
247+ f := renderer.NewFrame(size.Cols, size.Rows)
248+ CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil)
249+ })
250+ }
251+}

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