detached c15d164

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5858 }
5959
6060 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})
6262 }
6363
6464 func reconnectToastLines(bounds domain.Rect) []string {
@@ -70,7 +70,7 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
7070 return nil
7171 }
7272 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{
7474 Text: renderer.DefaultStyle(),
7575 Box: renderer.DefaultStyle(),
7676 }, 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 nil70 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) {
245245 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
246246 }
247247
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+
248253 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
249254 var out bytes.Buffer
250255 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.Buffer254 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 @@
11 package ui
22
33 import (
4- "strconv"
54 "time"
65
76 "github.com/bnema/vev/internal/domain"
@@ -14,22 +13,11 @@ const (
1413 defaultToastMaxWidth = 60
1514 )
1615
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-
2816 // Toast describes a transient message rendered over a frame.
2917 type Toast struct {
3018 ID string
3119 Message string
32- Anchor ToastAnchor
20+ Anchor domain.Anchor
3321 Duration time.Duration
3422 DimBackground bool
3523 MinWidth int
@@ -76,8 +64,8 @@ func (m *ToastManager) Show(now time.Time, toast Toast) {
7664 m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
7765 }
7866
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()
8169 }
8270
8371 // Dismiss removes a toast by ID.
@@ -136,32 +124,12 @@ func ToastBounds(base domain.Size, toast Toast) domain.Rect {
136124 height := 1 + toast.PaddingY*2 + borderHeight
137125 height = clamp(height, 0, base.Rows)
138126
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+ })
165133 }
166134
167135 // CompositeToasts draws the latest toast per anchor over frame.
@@ -196,14 +164,24 @@ func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastSty
196164 }
197165
198166 func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
199- byAnchor := make(map[ToastAnchor]ActiveToast)
167+ byAnchor := make(map[domain.Anchor]ActiveToast)
200168 for _, toast := range toasts {
201169 current, ok := byAnchor[toast.Anchor]
202170 if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
203171 byAnchor[toast.Anchor] = toast
204172 }
205173 }
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+ }
207185 visible := make([]ActiveToast, 0, len(byAnchor))
208186 for _, anchor := range anchors {
209187 if toast, ok := byAnchor[anchor]; ok {
@@ -1,7 +1,6 @@
1 package ui1 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 = 6013 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 string18 ID string
31 Message string19 Message string
32- Anchor ToastAnchor20+ Anchor domain.Anchor
33 Duration time.Duration21 Duration time.Duration
34 DimBackground bool22 DimBackground bool
35 MinWidth int23 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 + borderHeight124 height := 1 + toast.PaddingY*2 + borderHeight
137 height = clamp(height, 0, base.Rows)125 height = clamp(height, 0, base.Rows)
138 126
139- x := 0127+ return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{
140- y := 0128+ Top: defaultToastMargin,
141- switch toast.Anchor {129+ Right: defaultToastMargin,
142- case ToastTopLeft:130+ Bottom: defaultToastMargin,
143- x = defaultToastMargin131+ Left: defaultToastMargin,
144- y = defaultToastMargin132+ })
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] = toast171 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) {
5454 }
5555 })
5656
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+ }
6470 }
6571 })
6672
@@ -83,64 +89,29 @@ func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
8389 }
8490
8591 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
9096 }{
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}},
139110 }
140111
141112 for _, tt := range tests {
142113 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 {
144115 t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
145116 }
146117 })
@@ -155,8 +126,8 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
155126 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
156127 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
157128
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})
160131 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
161132 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
162133 }
@@ -174,7 +145,7 @@ func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
174145
175146 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
176147 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)
178149 assertRune(t, f, 0, 0, '┌')
179150 assertRune(t, f, 7, 0, '┐')
180151 assertRune(t, f, 2, 1, 'a')
@@ -190,9 +161,9 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
190161 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
191162
192163 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},
196167 }, styles, nil)
197168
198169 assertRune(t, f, 4, 3, 'N')
@@ -207,6 +178,33 @@ func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
207178 }
208179 }
209180
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+
210208 func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
211209 f := renderer.NewFrame(14, 5)
212210 baseStyle := renderer.Style{Foreground: 1, Background: 2}
@@ -218,7 +216,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
218216 }
219217 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
220218
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 {
222220 return dimStyle
223221 })
224222
@@ -229,7 +227,7 @@ func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
229227 if !outside.Style.Equal(dimStyle) {
230228 t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
231229 }
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})
233231 boxCell := f.At(bounds.X, bounds.Y)
234232 if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
235233 t.Fatalf("box cell = %+v, want toast box style", boxCell)
@@ -245,7 +243,7 @@ func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
245243 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
246244 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
247245 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)
249247 })
250248 }
251249 }
@@ -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 string92+ name string
87- base domain.Size93+ base domain.Size
88- make func() Toast94+ toast Toast
89- want domain.Rect95+ 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 dimStyle220 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 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close