detached 6f6fbbf

fix(selection): fade a visible toast along with the HUD during a drag

Keep the toast drawn until the HUD is hidden and render it at the
fainter of its own fade and the HUD fade, so it neither vanishes on drag
start nor pops back after a rejected release. The allocation guard now
restarts the fade every run and covers fade-out and fade-in.
commit
6f6fbbfd3b19c8f7685940554e044aab64dc6552
author
brice · · 2026-10-17 19:54 UTC
parent
4463e6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +57 -10
M internal/adapters/selection/fx.go +9 -3
@@ -24,8 +24,8 @@ const (
2424 hudSteps = 4 // HUD fade length in fxSteps: 120 ms each way
2525 )
2626
27-// hudClasses are the HUD opacities by fade level; level hudSteps is hidden.
28-var hudClasses = [hudSteps]string{"o0", "o3", "o5", "o7"}
27+// hudOpacity indexes opacityClasses by HUD fade level; level hudSteps is hidden.
28+var hudOpacity = [hudSteps]int{0, 3, 5, 7}
2929
3030 // opacityClasses are the toast steps of style.css, from opaque to faint.
3131 var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"}
@@ -210,8 +210,14 @@ func (m *model) fadeHUD(now time.Time) {
210210 }
211211 }
212212
213+// hudStep is the opacityClasses index of the HUD this frame.
214+func (m *model) hudStep() int { return hudOpacity[min(m.hud.level, hudSteps-1)] }
215+
213216 // hudClass is the opacity class of the HUD this frame.
214-func (m *model) hudClass() string { return hudClasses[min(m.hud.level, hudSteps-1)] }
217+func (m *model) hudClass() string { return opacityClasses[m.hudStep()] }
218+
219+// toastClass is the fainter of the toast's own fade and the HUD fade.
220+func (m *model) toastClass() string { return opacityClasses[max(m.toast.step, m.hudStep())] }
215221
216222 // hudHidden reports a fully faded HUD, which is not laid out or drawn.
217223 func (m *model) hudHidden() bool { return m.hud.level >= hudSteps }
@@ -24,8 +24,8 @@ const (
24 hudSteps = 4 // HUD fade length in fxSteps: 120 ms each way24 hudSteps = 4 // HUD fade length in fxSteps: 120 ms each way
25 )25 )
26 26
27-// hudClasses are the HUD opacities by fade level; level hudSteps is hidden.27+// hudOpacity indexes opacityClasses by HUD fade level; level hudSteps is hidden.
28-var hudClasses = [hudSteps]string{"o0", "o3", "o5", "o7"}28+var hudOpacity = [hudSteps]int{0, 3, 5, 7}
29 29
30 // opacityClasses are the toast steps of style.css, from opaque to faint.30 // opacityClasses are the toast steps of style.css, from opaque to faint.
31 var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"}31 var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"}
@@ -210,8 +210,14 @@ func (m *model) fadeHUD(now time.Time) {
210 }210 }
211 }211 }
212 212
213+// hudStep is the opacityClasses index of the HUD this frame.
214+func (m *model) hudStep() int { return hudOpacity[min(m.hud.level, hudSteps-1)] }
215+
213 // hudClass is the opacity class of the HUD this frame.216 // hudClass is the opacity class of the HUD this frame.
214-func (m *model) hudClass() string { return hudClasses[min(m.hud.level, hudSteps-1)] }217+func (m *model) hudClass() string { return opacityClasses[m.hudStep()] }
218+
219+// toastClass is the fainter of the toast's own fade and the HUD fade.
220+func (m *model) toastClass() string { return opacityClasses[max(m.toast.step, m.hudStep())] }
215 221
216 // hudHidden reports a fully faded HUD, which is not laid out or drawn.222 // hudHidden reports a fully faded HUD, which is not laid out or drawn.
217 func (m *model) hudHidden() bool { return m.hud.level >= hudSteps }223 func (m *model) hudHidden() bool { return m.hud.level >= hudSteps }
M internal/adapters/selection/fx_test.go +46 -5
@@ -190,21 +190,33 @@ func TestEffectsAllocations(t *testing.T) {
190190 })
191191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192192
193- // HUD fade frames during a drag allocate nothing; the drag's own size
194- // label is measured by the picker and footer tests.
193+ // A full HUD fade out and back in, with a toast fading along, allocates
194+ // nothing per frame. The fade restarts every run, so warm-up does not
195+ // leave it settled.
195196 m.input(button(100, 100, true))
196197 m.input(motion(400, 300))
197198 n = testing.AllocsPerRun(100, func() {
199+ m.hud = hudFade{}
200+ m.showToast(toastMonitor, targetHold, false)
198201 for i := range hudSteps + 1 {
199202 m.animate(now.Add(time.Duration(i) * fxStep))
200203 _ = m.scene()
201- _ = m.hudClass()
204+ _, _ = m.hudClass(), m.toastClass()
202205 }
203- now = now.Add(time.Second)
206+ m.hud.hiding = false // as after a rejected release
207+ for i := range hudSteps + 1 {
208+ m.animate(now.Add(time.Second + time.Duration(i)*fxStep))
209+ _ = m.scene()
210+ _, _ = m.hudClass(), m.toastClass()
211+ }
212+ m.hud.hiding = true
213+ now = now.Add(2 * time.Second)
204214 })
205215 assert.Zero(t, n, "HUD fade frames allocate nothing")
206216 m.input(motion(102, 102))
207217 m.input(button(102, 102, false))
218+ m.animate(now.Add(time.Minute))
219+ m.animate(now.Add(2 * time.Minute))
208220 assert.False(t, m.toast.on)
209221 assert.False(t, m.pulse.on)
210222
@@ -284,7 +296,7 @@ func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
284296
285297 m.input(motion(400, 300))
286298 require.True(t, m.picker.Dragging())
287- m.input(key("Tab")) // its toast must not show during the drag
299+ m.input(key("Tab")) // its toast fades along with the HUD
288300 levels := []int{}
289301 for i := range hudSteps + 1 {
290302 m.animate(t0.Add(time.Duration(i) * fxStep))
@@ -339,3 +351,32 @@ func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
339351 m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
340352 assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
341353 }
354+
355+func TestToastFadesWithTheHUD(t *testing.T) {
356+ m := newTestModel(t)
357+ t0 := time.Unix(1000, 0)
358+ m.input(key("m"))
359+ m.animate(t0)
360+ require.Equal(t, "o0", m.toastClass())
361+
362+ m.input(button(100, 100, true))
363+ m.input(motion(400, 300))
364+ classes := []string{}
365+ for i := range hudSteps - 1 {
366+ m.animate(t0.Add(time.Duration(i) * fxStep))
367+ classes = append(classes, m.toastClass())
368+ assert.Greater(t, m.scene().toast.w, 0.0, "drawn until the HUD is gone")
369+ }
370+ assert.Equal(t, []string{"o3", "o5", "o7"}, classes)
371+ m.animate(t0.Add((hudSteps - 1) * fxStep))
372+ assert.True(t, m.hudHidden())
373+ assert.Equal(t, frect{}, m.scene().toast)
374+
375+ m.input(motion(103, 103))
376+ m.input(button(103, 103, false))
377+ t1 := t0.Add(5 * fxStep)
378+ m.animate(t1)
379+ assert.Equal(t, "o7", m.toastClass(), "comes back faint, not at once")
380+ m.animate(t1.Add(fxStep))
381+ assert.Equal(t, "o5", m.toastClass())
382+}
@@ -190,21 +190,33 @@ func TestEffectsAllocations(t *testing.T) {
190 })190 })
191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192 192
193- // HUD fade frames during a drag allocate nothing; the drag's own size193+ // A full HUD fade out and back in, with a toast fading along, allocates
194- // label is measured by the picker and footer tests.194+ // nothing per frame. The fade restarts every run, so warm-up does not
195+ // leave it settled.
195 m.input(button(100, 100, true))196 m.input(button(100, 100, true))
196 m.input(motion(400, 300))197 m.input(motion(400, 300))
197 n = testing.AllocsPerRun(100, func() {198 n = testing.AllocsPerRun(100, func() {
199+ m.hud = hudFade{}
200+ m.showToast(toastMonitor, targetHold, false)
198 for i := range hudSteps + 1 {201 for i := range hudSteps + 1 {
199 m.animate(now.Add(time.Duration(i) * fxStep))202 m.animate(now.Add(time.Duration(i) * fxStep))
200 _ = m.scene()203 _ = m.scene()
201- _ = m.hudClass()204+ _, _ = m.hudClass(), m.toastClass()
202 }205 }
203- now = now.Add(time.Second)206+ m.hud.hiding = false // as after a rejected release
207+ for i := range hudSteps + 1 {
208+ m.animate(now.Add(time.Second + time.Duration(i)*fxStep))
209+ _ = m.scene()
210+ _, _ = m.hudClass(), m.toastClass()
211+ }
212+ m.hud.hiding = true
213+ now = now.Add(2 * time.Second)
204 })214 })
205 assert.Zero(t, n, "HUD fade frames allocate nothing")215 assert.Zero(t, n, "HUD fade frames allocate nothing")
206 m.input(motion(102, 102))216 m.input(motion(102, 102))
207 m.input(button(102, 102, false))217 m.input(button(102, 102, false))
218+ m.animate(now.Add(time.Minute))
219+ m.animate(now.Add(2 * time.Minute))
208 assert.False(t, m.toast.on)220 assert.False(t, m.toast.on)
209 assert.False(t, m.pulse.on)221 assert.False(t, m.pulse.on)
210 222
@@ -284,7 +296,7 @@ func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
284 296
285 m.input(motion(400, 300))297 m.input(motion(400, 300))
286 require.True(t, m.picker.Dragging())298 require.True(t, m.picker.Dragging())
287- m.input(key("Tab")) // its toast must not show during the drag299+ m.input(key("Tab")) // its toast fades along with the HUD
288 levels := []int{}300 levels := []int{}
289 for i := range hudSteps + 1 {301 for i := range hudSteps + 1 {
290 m.animate(t0.Add(time.Duration(i) * fxStep))302 m.animate(t0.Add(time.Duration(i) * fxStep))
@@ -339,3 +351,32 @@ func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
339 m.animate(time.Unix(1000, 0)) // level 1: chips still drawn351 m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
340 assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)352 assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
341 }353 }
354+
355+func TestToastFadesWithTheHUD(t *testing.T) {
356+ m := newTestModel(t)
357+ t0 := time.Unix(1000, 0)
358+ m.input(key("m"))
359+ m.animate(t0)
360+ require.Equal(t, "o0", m.toastClass())
361+
362+ m.input(button(100, 100, true))
363+ m.input(motion(400, 300))
364+ classes := []string{}
365+ for i := range hudSteps - 1 {
366+ m.animate(t0.Add(time.Duration(i) * fxStep))
367+ classes = append(classes, m.toastClass())
368+ assert.Greater(t, m.scene().toast.w, 0.0, "drawn until the HUD is gone")
369+ }
370+ assert.Equal(t, []string{"o3", "o5", "o7"}, classes)
371+ m.animate(t0.Add((hudSteps - 1) * fxStep))
372+ assert.True(t, m.hudHidden())
373+ assert.Equal(t, frect{}, m.scene().toast)
374+
375+ m.input(motion(103, 103))
376+ m.input(button(103, 103, false))
377+ t1 := t0.Add(5 * fxStep)
378+ m.animate(t1)
379+ assert.Equal(t, "o7", m.toastClass(), "comes back faint, not at once")
380+ m.animate(t1.Add(fxStep))
381+ assert.Equal(t, "o5", m.toastClass())
382+}
M internal/adapters/selection/scene.go +1 -1
@@ -197,7 +197,7 @@ func (m *model) scene() scene {
197197 fw := min(footerW, w)
198198 fh := min(footerHeight(m.footerText, fw), h)
199199 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
200- if m.toast.on && m.hud.level == 0 {
200+ if m.toast.on {
201201 tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
202202 s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
203203 }
@@ -197,7 +197,7 @@ func (m *model) scene() scene {
197 fw := min(footerW, w)197 fw := min(footerW, w)
198 fh := min(footerHeight(m.footerText, fw), h)198 fh := min(footerHeight(m.footerText, fw), h)
199 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}199 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
200- if m.toast.on && m.hud.level == 0 {200+ if m.toast.on {
201 tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)201 tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
202 s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}202 s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
203 }203 }
M internal/adapters/selection/view.go +1 -1
@@ -58,7 +58,7 @@ func view(f *nefergui.Frame, m *model) {
5858 kind = "warn"
5959 }
6060 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),
61- nefergui.Class(opacityClasses[m.toast.step])).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text)
61+ nefergui.Class(m.toastClass())).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text)
6262 }
6363 }
6464
@@ -58,7 +58,7 @@ func view(f *nefergui.Frame, m *model) {
58 kind = "warn"58 kind = "warn"
59 }59 }
60 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),60 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),
61- nefergui.Class(opacityClasses[m.toast.step])).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text)61+ nefergui.Class(m.toastClass())).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text)
62 }62 }
63 }63 }
64 64

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 2

Tags 9

↑↓ moveenter openesc close