refactor(ui): unify toast anchoring
- commit
- c15d164fe09d6b6ebe33aae691a55459cc1e3ea4
- author
- brice · · 2026-07-11 06:27 UTC
- committer
- brice · · 2026-07-11 06:31 UTC
- parent
- 292e666
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +95 -114
M internal/usecase/client/reconnect_toast.go +2 -2
| @@ -58,7 +58,7 @@ func reconnectToastBounds(size domain.Size) domain.Rect { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | |
| 61 | - | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter}) | |
| 61 | + | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter}) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 64 | func reconnectToastLines(bounds domain.Rect) []string { | |
| @@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |||
| 70 | 70 | return nil | |
| 71 | 71 | } | |
| 72 | 72 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | |
| 73 | - | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{ | |
| 73 | + | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{ | |
| 74 | 74 | Text: renderer.DefaultStyle(), | |
| 75 | 75 | Box: renderer.DefaultStyle(), | |
| 76 | 76 | }, nil) | |
| @@ -58,7 +58,7 @@ func reconnectToastBounds(size domain.Size) domain.Rect { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | 60 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | ||
| 61 | - | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter}) | 61 | + | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter}) |
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | func reconnectToastLines(bounds domain.Rect) []string { | 64 | func reconnectToastLines(bounds domain.Rect) []string { | ||
| @@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |||||
| 70 | return nil | 70 | return nil | ||
| 71 | } | 71 | } | ||
| 72 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | 72 | frame := renderer.NewFrame(bounds.Width, bounds.Height) | ||
| 73 | - | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{ | 73 | + | ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: domain.AnchorCenter}}}, ui.ToastStyles{ |
| 74 | Text: renderer.DefaultStyle(), | 74 | Text: renderer.DefaultStyle(), | ||
| 75 | Box: renderer.DefaultStyle(), | 75 | Box: renderer.DefaultStyle(), | ||
| 76 | }, nil) | 76 | }, nil) | ||
M internal/usecase/client/reconnect_toast_test.go +5 -0
| @@ -245,6 +245,11 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 245 | 245 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | |
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | + | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | |
| 249 | + | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | |
| 250 | + | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | |
| 251 | + | } | |
| 252 | + | ||
| 248 | 253 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 249 | 254 | var out bytes.Buffer | |
| 250 | 255 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -245,6 +245,11 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 245 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | 245 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | ||
| 246 | } | 246 | } | ||
| 247 | 247 | ||||
| 248 | + | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | |||
| 249 | + | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | |||
| 250 | + | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 248 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | 253 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | ||
| 249 | var out bytes.Buffer | 254 | var out bytes.Buffer | ||
| 250 | size := domain.Size{Cols: 80, Rows: 24} | 255 | size := domain.Size{Cols: 80, Rows: 24} | ||
M internal/usecase/ui/toast.go +21 -43
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strconv" | |
| 5 | 4 | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/internal/domain" | |
| @@ -14,22 +13,11 @@ const ( | |||
| 14 | 13 | defaultToastMaxWidth = 60 | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 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 | 16 | // Toast describes a transient message rendered over a frame. | |
| 29 | 17 | type Toast struct { | |
| 30 | 18 | ID string | |
| 31 | 19 | Message string | |
| 32 | - | Anchor ToastAnchor | |
| 20 | + | Anchor domain.Anchor | |
| 33 | 21 | Duration time.Duration | |
| 34 | 22 | DimBackground bool | |
| 35 | 23 | MinWidth int | |
| @@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) { | |||
| 76 | 64 | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | |
| 77 | 65 | } | |
| 78 | 66 | ||
| 79 | - | func toastAnchorID(anchor ToastAnchor) string { | |
| 80 | - | return "anchor:" + strconv.Itoa(int(anchor)) | |
| 67 | + | func toastAnchorID(anchor domain.Anchor) string { | |
| 68 | + | return "anchor:" + anchor.String() | |
| 81 | 69 | } | |
| 82 | 70 | ||
| 83 | 71 | // Dismiss removes a toast by ID. | |
| @@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |||
| 136 | 124 | height := 1 + toast.PaddingY*2 + borderHeight | |
| 137 | 125 | height = clamp(height, 0, base.Rows) | |
| 138 | 126 | ||
| 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 | - | } | |
| 127 | + | return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{ | |
| 128 | + | Top: defaultToastMargin, | |
| 129 | + | Right: defaultToastMargin, | |
| 130 | + | Bottom: defaultToastMargin, | |
| 131 | + | Left: defaultToastMargin, | |
| 132 | + | }) | |
| 165 | 133 | } | |
| 166 | 134 | ||
| 167 | 135 | // CompositeToasts draws the latest toast per anchor over frame. | |
| @@ -196,14 +164,24 @@ func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastSty | |||
| 196 | 164 | } | |
| 197 | 165 | ||
| 198 | 166 | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | |
| 199 | - | byAnchor := make(map[ToastAnchor]ActiveToast) | |
| 167 | + | byAnchor := make(map[domain.Anchor]ActiveToast) | |
| 200 | 168 | for _, toast := range toasts { | |
| 201 | 169 | current, ok := byAnchor[toast.Anchor] | |
| 202 | 170 | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | |
| 203 | 171 | byAnchor[toast.Anchor] = toast | |
| 204 | 172 | } | |
| 205 | 173 | } | |
| 206 | - | anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter} | |
| 174 | + | anchors := []domain.Anchor{ | |
| 175 | + | domain.AnchorTopLeft, | |
| 176 | + | domain.AnchorTopRight, | |
| 177 | + | domain.AnchorBottomLeft, | |
| 178 | + | domain.AnchorBottomRight, | |
| 179 | + | domain.AnchorCenter, | |
| 180 | + | domain.AnchorTop, | |
| 181 | + | domain.AnchorLeft, | |
| 182 | + | domain.AnchorRight, | |
| 183 | + | domain.AnchorBottom, | |
| 184 | + | } | |
| 207 | 185 | visible := make([]ActiveToast, 0, len(byAnchor)) | |
| 208 | 186 | for _, anchor := range anchors { | |
| 209 | 187 | if toast, ok := byAnchor[anchor]; ok { | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strconv" | |||
| 5 | "time" | 4 | "time" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| @@ -14,22 +13,11 @@ const ( | |||||
| 14 | defaultToastMaxWidth = 60 | 13 | defaultToastMaxWidth = 60 | ||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| 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. | 16 | // Toast describes a transient message rendered over a frame. | ||
| 29 | type Toast struct { | 17 | type Toast struct { | ||
| 30 | ID string | 18 | ID string | ||
| 31 | Message string | 19 | Message string | ||
| 32 | - | Anchor ToastAnchor | 20 | + | Anchor domain.Anchor |
| 33 | Duration time.Duration | 21 | Duration time.Duration | ||
| 34 | DimBackground bool | 22 | DimBackground bool | ||
| 35 | MinWidth int | 23 | MinWidth int | ||
| @@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) { | |||||
| 76 | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | 64 | m.active[id] = ActiveToast{Toast: toast, ShownAt: now} | ||
| 77 | } | 65 | } | ||
| 78 | 66 | ||||
| 79 | - | func toastAnchorID(anchor ToastAnchor) string { | 67 | + | func toastAnchorID(anchor domain.Anchor) string { |
| 80 | - | return "anchor:" + strconv.Itoa(int(anchor)) | 68 | + | return "anchor:" + anchor.String() |
| 81 | } | 69 | } | ||
| 82 | 70 | ||||
| 83 | // Dismiss removes a toast by ID. | 71 | // Dismiss removes a toast by ID. | ||
| @@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect { | |||||
| 136 | height := 1 + toast.PaddingY*2 + borderHeight | 124 | height := 1 + toast.PaddingY*2 + borderHeight | ||
| 137 | height = clamp(height, 0, base.Rows) | 125 | height = clamp(height, 0, base.Rows) | ||
| 138 | 126 | ||||
| 139 | - | x := 0 | 127 | + | return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{ |
| 140 | - | y := 0 | 128 | + | Top: defaultToastMargin, |
| 141 | - | switch toast.Anchor { | 129 | + | Right: defaultToastMargin, |
| 142 | - | case ToastTopLeft: | 130 | + | Bottom: defaultToastMargin, |
| 143 | - | x = defaultToastMargin | 131 | + | Left: defaultToastMargin, |
| 144 | - | y = defaultToastMargin | 132 | + | }) |
| 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 | } | 133 | } | ||
| 166 | 134 | ||||
| 167 | // CompositeToasts draws the latest toast per anchor over frame. | 135 | // CompositeToasts draws the latest toast per anchor over frame. | ||
| @@ -196,14 +164,24 @@ func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastSty | |||||
| 196 | } | 164 | } | ||
| 197 | 165 | ||||
| 198 | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | 166 | func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast { | ||
| 199 | - | byAnchor := make(map[ToastAnchor]ActiveToast) | 167 | + | byAnchor := make(map[domain.Anchor]ActiveToast) |
| 200 | for _, toast := range toasts { | 168 | for _, toast := range toasts { | ||
| 201 | current, ok := byAnchor[toast.Anchor] | 169 | current, ok := byAnchor[toast.Anchor] | ||
| 202 | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | 170 | if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) { | ||
| 203 | byAnchor[toast.Anchor] = toast | 171 | byAnchor[toast.Anchor] = toast | ||
| 204 | } | 172 | } | ||
| 205 | } | 173 | } | ||
| 206 | - | anchors := []ToastAnchor{ToastTopLeft, ToastTopRight, ToastBottomLeft, ToastBottomRight, ToastCenter} | 174 | + | anchors := []domain.Anchor{ |
| 175 | + | domain.AnchorTopLeft, | |||
| 176 | + | domain.AnchorTopRight, | |||
| 177 | + | domain.AnchorBottomLeft, | |||
| 178 | + | domain.AnchorBottomRight, | |||
| 179 | + | domain.AnchorCenter, | |||
| 180 | + | domain.AnchorTop, | |||
| 181 | + | domain.AnchorLeft, | |||
| 182 | + | domain.AnchorRight, | |||
| 183 | + | domain.AnchorBottom, | |||
| 184 | + | } | |||
| 207 | visible := make([]ActiveToast, 0, len(byAnchor)) | 185 | visible := make([]ActiveToast, 0, len(byAnchor)) | ||
| 208 | for _, anchor := range anchors { | 186 | for _, anchor := range anchors { | ||
| 209 | if toast, ok := byAnchor[anchor]; ok { | 187 | if toast, ok := byAnchor[anchor]; ok { | ||
M internal/usecase/ui/toast_test.go +67 -69
| @@ -54,13 +54,19 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) { | |||
| 54 | 54 | } | |
| 55 | 55 | }) | |
| 56 | 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) | |
| 57 | + | t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) { | |
| 58 | + | anchors := []domain.Anchor{ | |
| 59 | + | domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, | |
| 60 | + | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight, | |
| 61 | + | } | |
| 62 | + | for _, anchor := range anchors { | |
| 63 | + | m := NewToastManager() | |
| 64 | + | m.Show(now, Toast{Message: "first", Anchor: anchor}) | |
| 65 | + | m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor}) | |
| 66 | + | active := m.Active(now.Add(time.Second)) | |
| 67 | + | if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() { | |
| 68 | + | t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active) | |
| 69 | + | } | |
| 64 | 70 | } | |
| 65 | 71 | }) | |
| 66 | 72 | ||
| @@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |||
| 83 | 89 | } | |
| 84 | 90 | ||
| 85 | 91 | tests := []struct { | |
| 86 | - | name string | |
| 87 | - | base domain.Size | |
| 88 | - | make func() Toast | |
| 89 | - | want domain.Rect | |
| 92 | + | name string | |
| 93 | + | base domain.Size | |
| 94 | + | toast Toast | |
| 95 | + | want domain.Rect | |
| 90 | 96 | }{ | |
| 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 | - | }, | |
| 97 | + | {"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}}, | |
| 98 | + | {"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}}, | |
| 99 | + | {"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}}, | |
| 100 | + | {"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}}, | |
| 101 | + | {"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}}, | |
| 102 | + | {"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}}, | |
| 103 | + | {"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}}, | |
| 104 | + | {"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}}, | |
| 105 | + | {"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}}, | |
| 106 | + | {"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}}, | |
| 107 | + | {"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}}, | |
| 108 | + | {"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}}, | |
| 109 | + | {"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}}, | |
| 139 | 110 | } | |
| 140 | 111 | ||
| 141 | 112 | for _, tt := range tests { | |
| 142 | 113 | t.Run(tt.name, func(t *testing.T) { | |
| 143 | - | if got := ToastBounds(tt.base, tt.make()); got != tt.want { | |
| 114 | + | if got := ToastBounds(tt.base, tt.toast); got != tt.want { | |
| 144 | 115 | t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want) | |
| 145 | 116 | } | |
| 146 | 117 | }) | |
| @@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 155 | 126 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 156 | 127 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | |
| 157 | 128 | ||
| 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}) | |
| 129 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil) | |
| 130 | + | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | |
| 160 | 131 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | |
| 161 | 132 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | |
| 162 | 133 | } | |
| @@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||
| 174 | 145 | ||
| 175 | 146 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | |
| 176 | 147 | f := renderer.NewFrame(8, 3) | |
| 177 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil) | |
| 148 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil) | |
| 178 | 149 | assertRune(t, f, 0, 0, '┌') | |
| 179 | 150 | assertRune(t, f, 7, 0, '┐') | |
| 180 | 151 | assertRune(t, f, 2, 1, 'a') | |
| @@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |||
| 190 | 161 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | |
| 191 | 162 | ||
| 192 | 163 | 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}, | |
| 164 | + | {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now}, | |
| 165 | + | {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)}, | |
| 166 | + | {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now}, | |
| 196 | 167 | }, styles, nil) | |
| 197 | 168 | ||
| 198 | 169 | assertRune(t, f, 4, 3, 'N') | |
| @@ -207,6 +178,33 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |||
| 207 | 178 | } | |
| 208 | 179 | } | |
| 209 | 180 | ||
| 181 | + | func TestLatestToastsByAnchorTieAndOrder(t *testing.T) { | |
| 182 | + | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |
| 183 | + | anchors := []domain.Anchor{ | |
| 184 | + | domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft, | |
| 185 | + | domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop, | |
| 186 | + | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom, | |
| 187 | + | } | |
| 188 | + | toasts := make([]ActiveToast, 0, len(anchors)+1) | |
| 189 | + | for _, anchor := range anchors { | |
| 190 | + | toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now}) | |
| 191 | + | } | |
| 192 | + | toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now}) | |
| 193 | + | ||
| 194 | + | visible := latestToastsByAnchor(toasts) | |
| 195 | + | if len(visible) != len(anchors) { | |
| 196 | + | t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors)) | |
| 197 | + | } | |
| 198 | + | for i, anchor := range anchors { | |
| 199 | + | if visible[i].Anchor != anchor { | |
| 200 | + | t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | if visible[0].ID != "later-input" { | |
| 204 | + | t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 210 | 208 | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |
| 211 | 209 | f := renderer.NewFrame(14, 5) | |
| 212 | 210 | baseStyle := renderer.Style{Foreground: 1, Background: 2} | |
| @@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||
| 218 | 216 | } | |
| 219 | 217 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | |
| 220 | 218 | ||
| 221 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { | |
| 219 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { | |
| 222 | 220 | return dimStyle | |
| 223 | 221 | }) | |
| 224 | 222 | ||
| @@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||
| 229 | 227 | if !outside.Style.Equal(dimStyle) { | |
| 230 | 228 | t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle) | |
| 231 | 229 | } | |
| 232 | - | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter}) | |
| 230 | + | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) | |
| 233 | 231 | boxCell := f.At(bounds.X, bounds.Y) | |
| 234 | 232 | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | |
| 235 | 233 | t.Fatalf("box cell = %+v, want toast box style", boxCell) | |
| @@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) { | |||
| 245 | 243 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | |
| 246 | 244 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |
| 247 | 245 | f := renderer.NewFrame(size.Cols, size.Rows) | |
| 248 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil) | |
| 246 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil) | |
| 249 | 247 | }) | |
| 250 | 248 | } | |
| 251 | 249 | } | |
| @@ -54,13 +54,19 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) { | |||||
| 54 | } | 54 | } | ||
| 55 | }) | 55 | }) | ||
| 56 | 56 | ||||
| 57 | - | t.Run("empty IDs replace per anchor", func(t *testing.T) { | 57 | + | t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) { |
| 58 | - | m := NewToastManager() | 58 | + | anchors := []domain.Anchor{ |
| 59 | - | m.Show(now, Toast{Message: "first", Anchor: ToastCenter}) | 59 | + | domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, |
| 60 | - | m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: ToastCenter}) | 60 | + | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight, |
| 61 | - | active := m.Active(now.Add(time.Second)) | 61 | + | } |
| 62 | - | if len(active) != 1 || active[0].Message != "second" { | 62 | + | for _, anchor := range anchors { |
| 63 | - | t.Fatalf("Active() after same-anchor replacement = %+v, want only second toast", active) | 63 | + | m := NewToastManager() |
| 64 | + | m.Show(now, Toast{Message: "first", Anchor: anchor}) | |||
| 65 | + | m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor}) | |||
| 66 | + | active := m.Active(now.Add(time.Second)) | |||
| 67 | + | if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() { | |||
| 68 | + | t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active) | |||
| 69 | + | } | |||
| 64 | } | 70 | } | ||
| 65 | }) | 71 | }) | ||
| 66 | 72 | ||||
| @@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |||||
| 83 | } | 89 | } | ||
| 84 | 90 | ||||
| 85 | tests := []struct { | 91 | tests := []struct { | ||
| 86 | - | name string | 92 | + | name string |
| 87 | - | base domain.Size | 93 | + | base domain.Size |
| 88 | - | make func() Toast | 94 | + | toast Toast |
| 89 | - | want domain.Rect | 95 | + | want domain.Rect |
| 90 | }{ | 96 | }{ | ||
| 91 | - | { | 97 | + | {"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}}, |
| 92 | - | name: "center", | 98 | + | {"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}}, |
| 93 | - | base: domain.Size{Cols: 80, Rows: 24}, | 99 | + | {"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}}, |
| 94 | - | make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastCenter} }, | 100 | + | {"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}}, |
| 95 | - | want: domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}, | 101 | + | {"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}}, |
| 96 | - | }, | 102 | + | {"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}}, |
| 97 | - | { | 103 | + | {"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}}, |
| 98 | - | name: "top left has two cell margins", | 104 | + | {"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}}, |
| 99 | - | base: domain.Size{Cols: 80, Rows: 24}, | 105 | + | {"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}}, |
| 100 | - | make: func() Toast { return Toast{Message: "Reconnection attempts…", Anchor: ToastTopLeft} }, | 106 | + | {"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}}, |
| 101 | - | want: domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}, | 107 | + | {"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}}, |
| 102 | - | }, | 108 | + | {"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}}, |
| 103 | - | { | 109 | + | {"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}}, |
| 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 | } | 110 | } | ||
| 140 | 111 | ||||
| 141 | for _, tt := range tests { | 112 | for _, tt := range tests { | ||
| 142 | t.Run(tt.name, func(t *testing.T) { | 113 | t.Run(tt.name, func(t *testing.T) { | ||
| 143 | - | if got := ToastBounds(tt.base, tt.make()); got != tt.want { | 114 | + | if got := ToastBounds(tt.base, tt.toast); got != tt.want { |
| 144 | t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want) | 115 | t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want) | ||
| 145 | } | 116 | } | ||
| 146 | }) | 117 | }) | ||
| @@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||||
| 155 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | 126 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | ||
| 156 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | 127 | FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior) | ||
| 157 | 128 | ||||
| 158 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: ToastCenter}}}, styles, nil) | 129 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil) |
| 159 | - | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: ToastCenter}) | 130 | + | bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) |
| 160 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | 131 | if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) { | ||
| 161 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | 132 | t.Fatalf("bounds = %+v, want centered 6x3 box", bounds) | ||
| 162 | } | 133 | } | ||
| @@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) { | |||||
| 174 | 145 | ||||
| 175 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | 146 | t.Run("narrow toast truncates with ellipsis", func(t *testing.T) { | ||
| 176 | f := renderer.NewFrame(8, 3) | 147 | f := renderer.NewFrame(8, 3) | ||
| 177 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: ToastCenter, MaxWidth: 8}}}, styles, nil) | 148 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil) |
| 178 | assertRune(t, f, 0, 0, '┌') | 149 | assertRune(t, f, 0, 0, '┌') | ||
| 179 | assertRune(t, f, 7, 0, '┐') | 150 | assertRune(t, f, 7, 0, '┐') | ||
| 180 | assertRune(t, f, 2, 1, 'a') | 151 | assertRune(t, f, 2, 1, 'a') | ||
| @@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |||||
| 190 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | 161 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}} | ||
| 191 | 162 | ||||
| 192 | CompositeToasts(f, []ActiveToast{ | 163 | CompositeToasts(f, []ActiveToast{ | ||
| 193 | - | {Toast: Toast{ID: "old", Message: "Old", Anchor: ToastTopLeft}, ShownAt: now}, | 164 | + | {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now}, |
| 194 | - | {Toast: Toast{ID: "new", Message: "New", Anchor: ToastTopLeft}, ShownAt: now.Add(time.Second)}, | 165 | + | {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)}, |
| 195 | - | {Toast: Toast{ID: "center", Message: "Mid", Anchor: ToastCenter}, ShownAt: now}, | 166 | + | {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now}, |
| 196 | }, styles, nil) | 167 | }, styles, nil) | ||
| 197 | 168 | ||||
| 198 | assertRune(t, f, 4, 3, 'N') | 169 | assertRune(t, f, 4, 3, 'N') | ||
| @@ -207,6 +178,33 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) { | |||||
| 207 | } | 178 | } | ||
| 208 | } | 179 | } | ||
| 209 | 180 | ||||
| 181 | + | func TestLatestToastsByAnchorTieAndOrder(t *testing.T) { | |||
| 182 | + | now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) | |||
| 183 | + | anchors := []domain.Anchor{ | |||
| 184 | + | domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft, | |||
| 185 | + | domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop, | |||
| 186 | + | domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom, | |||
| 187 | + | } | |||
| 188 | + | toasts := make([]ActiveToast, 0, len(anchors)+1) | |||
| 189 | + | for _, anchor := range anchors { | |||
| 190 | + | toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now}) | |||
| 191 | + | } | |||
| 192 | + | toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now}) | |||
| 193 | + | ||||
| 194 | + | visible := latestToastsByAnchor(toasts) | |||
| 195 | + | if len(visible) != len(anchors) { | |||
| 196 | + | t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors)) | |||
| 197 | + | } | |||
| 198 | + | for i, anchor := range anchors { | |||
| 199 | + | if visible[i].Anchor != anchor { | |||
| 200 | + | t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | if visible[0].ID != "later-input" { | |||
| 204 | + | t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 210 | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | 208 | func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | ||
| 211 | f := renderer.NewFrame(14, 5) | 209 | f := renderer.NewFrame(14, 5) | ||
| 212 | baseStyle := renderer.Style{Foreground: 1, Background: 2} | 210 | baseStyle := renderer.Style{Foreground: 1, Background: 2} | ||
| @@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||||
| 218 | } | 216 | } | ||
| 219 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | 217 | styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}} | ||
| 220 | 218 | ||||
| 221 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: ToastCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { | 219 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style { |
| 222 | return dimStyle | 220 | return dimStyle | ||
| 223 | }) | 221 | }) | ||
| 224 | 222 | ||||
| @@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) { | |||||
| 229 | if !outside.Style.Equal(dimStyle) { | 227 | if !outside.Style.Equal(dimStyle) { | ||
| 230 | t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle) | 228 | t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle) | ||
| 231 | } | 229 | } | ||
| 232 | - | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: ToastCenter}) | 230 | + | bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter}) |
| 233 | boxCell := f.At(bounds.X, bounds.Y) | 231 | boxCell := f.At(bounds.X, bounds.Y) | ||
| 234 | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | 232 | if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) { | ||
| 235 | t.Fatalf("box cell = %+v, want toast box style", boxCell) | 233 | t.Fatalf("box cell = %+v, want toast box style", boxCell) | ||
| @@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) { | |||||
| 245 | for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} { | 243 | 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) { | 244 | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | ||
| 247 | f := renderer.NewFrame(size.Cols, size.Rows) | 245 | f := renderer.NewFrame(size.Cols, size.Rows) | ||
| 248 | - | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: ToastCenter}}}, styles, nil) | 246 | + | CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil) |
| 249 | }) | 247 | }) | ||
| 250 | } | 248 | } | ||
| 251 | } | 249 | } | ||