Merge pull request #25 from bnema/feat/toast-notifications
feat(ui): add reconnect toast notifications
5 files · +880 -9
M internal/usecase/client/client.go +64 -9
| @@ -110,6 +110,7 @@ type reconnectBackoff struct { | |||
| 110 | 110 | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | |
| 111 | 111 | ||
| 112 | 112 | var reconnectSleep = sleepReconnect | |
| 113 | + | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 113 | 114 | ||
| 114 | 115 | const ( | |
| 115 | 116 | statusReconnect = "\r\x1b[2Kreconnecting…" | |
| @@ -165,32 +166,65 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 165 | 166 | attemptIntent := intent | |
| 166 | 167 | backoff := defaultReconnectBackoff.initial | |
| 167 | 168 | showingStatus := false | |
| 168 | - | drawStatus := func() { | |
| 169 | - | if !rawEntered || showingStatus { | |
| 169 | + | var reconnectToastSize domain.Size | |
| 170 | + | redrawRemoteStatus := func(size domain.Size) { | |
| 171 | + | if !rawEntered || !remote { | |
| 170 | 172 | 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 | 179 | _ = term.Flush() | |
| 174 | 180 | 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 | 198 | clearStatus := func() { | |
| 177 | 199 | if !showingStatus { | |
| 178 | 200 | 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 | 207 | _ = term.Flush() | |
| 182 | 208 | 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 | 217 | for { | |
| 186 | 218 | transport, err := dialer.Dial(ctx) | |
| 187 | 219 | if err != nil { | |
| 188 | 220 | if resumeToken == 0 || ctx.Err() != nil { | |
| 221 | + | clearStatus() | |
| 189 | 222 | return err | |
| 190 | 223 | } | |
| 191 | 224 | log.Warn("reconnect dial failed", "err", err, "backoff", backoff) | |
| 192 | 225 | drawStatus() | |
| 193 | - | if !reconnectSleep(ctx, backoff) { | |
| 226 | + | if !sleepWhileReconnecting(backoff) { | |
| 227 | + | clearStatus() | |
| 194 | 228 | return ctx.Err() | |
| 195 | 229 | } | |
| 196 | 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 | 252 | log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff) | |
| 219 | 253 | drawStatus() | |
| 220 | - | if !reconnectSleep(ctx, backoff) { | |
| 254 | + | if !sleepWhileReconnecting(backoff) { | |
| 221 | 255 | clearStatus() | |
| 222 | 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 { | |
| 230 | - | t := time.NewTimer(d) | |
| 263 | + | func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool { | |
| 264 | + | t := clk.NewTimer(d) | |
| 231 | 265 | defer t.Stop() | |
| 232 | 266 | select { | |
| 233 | - | case <-t.C: | |
| 267 | + | case <-t.C(): | |
| 234 | 268 | return true | |
| 235 | 269 | case <-ctx.Done(): | |
| 236 | 270 | 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 | 295 | func nextReconnectBackoff(cur, limit time.Duration) time.Duration { | |
| 241 | 296 | cur *= 2 | |
| 242 | 297 | 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 = sleepReconnect | 112 | 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 := intent | 166 | attemptIntent := intent | ||
| 166 | backoff := defaultReconnectBackoff.initial | 167 | backoff := defaultReconnectBackoff.initial | ||
| 167 | showingStatus := false | 168 | 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 | return | 172 | 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 = true | 180 | 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 | return | 200 | 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 = false | 208 | 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 err | 222 | 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 true | 268 | return true | ||
| 235 | case <-ctx.Done(): | 269 | case <-ctx.Done(): | ||
| 236 | return false | 270 | 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 *= 2 | 296 | 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 | + | } | |||