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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +57 -10
M internal/adapters/selection/fx.go +9 -3
| @@ -24,8 +24,8 @@ const ( | |||
| 24 | 24 | 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. | |
| 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} | |
| 29 | 29 | ||
| 30 | 30 | // opacityClasses are the toast steps of style.css, from opaque to faint. | |
| 31 | 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 | 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 | 222 | // hudHidden reports a fully faded HUD, which is not laid out or drawn. | |
| 217 | 223 | 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 way | 24 | 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) { | |||
| 190 | 190 | }) | |
| 191 | 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 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. | |
| 195 | 196 | m.input(button(100, 100, true)) | |
| 196 | 197 | m.input(motion(400, 300)) | |
| 197 | 198 | n = testing.AllocsPerRun(100, func() { | |
| 199 | + | m.hud = hudFade{} | |
| 200 | + | m.showToast(toastMonitor, targetHold, false) | |
| 198 | 201 | for i := range hudSteps + 1 { | |
| 199 | 202 | m.animate(now.Add(time.Duration(i) * fxStep)) | |
| 200 | 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 | 215 | assert.Zero(t, n, "HUD fade frames allocate nothing") | |
| 206 | 216 | m.input(motion(102, 102)) | |
| 207 | 217 | m.input(button(102, 102, false)) | |
| 218 | + | m.animate(now.Add(time.Minute)) | |
| 219 | + | m.animate(now.Add(2 * time.Minute)) | |
| 208 | 220 | assert.False(t, m.toast.on) | |
| 209 | 221 | assert.False(t, m.pulse.on) | |
| 210 | 222 | ||
| @@ -284,7 +296,7 @@ func TestDragFadesTheHUDOutAndBackIn(t *testing.T) { | |||
| 284 | 296 | ||
| 285 | 297 | m.input(motion(400, 300)) | |
| 286 | 298 | 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 | |
| 288 | 300 | levels := []int{} | |
| 289 | 301 | for i := range hudSteps + 1 { | |
| 290 | 302 | m.animate(t0.Add(time.Duration(i) * fxStep)) | |
| @@ -339,3 +351,32 @@ func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) { | |||
| 339 | 351 | m.animate(time.Unix(1000, 0)) // level 1: chips still drawn | |
| 340 | 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 | + | } | |
| @@ -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 size | 193 | + | // 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 drag | 299 | + | 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 drawn | 351 | 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 { | |||
| 197 | 197 | fw := min(footerW, w) | |
| 198 | 198 | fh := min(footerHeight(m.footerText, fw), h) | |
| 199 | 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 | 201 | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | |
| 202 | 202 | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | |
| 203 | 203 | } | |
| @@ -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) { | |||
| 58 | 58 | kind = "warn" | |
| 59 | 59 | } | |
| 60 | 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 | ||
| @@ -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 | ||||