fix(selection): fade the HUD out while a region is dragged
Chips, header, footer and toast fade out in about 120 ms once a drag starts, leaving the region, its size, the dimming and the guides, and fade back in when picking continues. A reversal starts from the current opacity. The drag's size label keeps its place above the region.
- commit
- 4463e6ace52c26c24c166759a68b673db71eb66c
- author
- brice · · 2026-10-17 19:50 UTC
- parent
- f8b25f6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +154 -16
M README.md +1 -1
| @@ -24,7 +24,7 @@ bind.cmd+p = spawn nefercap | |||
| 24 | 24 | ||
| 25 | 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. | |
| 26 | 26 | ||
| 27 | - | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, red for recordings. Each key press also shows a short toast in the center and pulses the border. | |
| 27 | + | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, red for recordings. Each key press also shows a short toast in the center and pulses the border. While you drag a region, everything else fades out, leaving the region, its size and the guides. | |
| 28 | 28 | ||
| 29 | 29 | After a screenshot, a green frame briefly flashes over the captured area. It is drawn after the capture, so it never appears in the image. | |
| 30 | 30 | ||
| @@ -24,7 +24,7 @@ bind.cmd+p = spawn nefercap | |||||
| 24 | 24 | ||||
| 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. | 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. | ||
| 26 | 26 | ||||
| 27 | - | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, red for recordings. Each key press also shows a short toast in the center and pulses the border. | 27 | + | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, red for recordings. Each key press also shows a short toast in the center and pulses the border. While you drag a region, everything else fades out, leaving the region, its size and the guides. |
| 28 | 28 | ||||
| 29 | After a screenshot, a green frame briefly flashes over the captured area. It is drawn after the capture, so it never appears in the image. | 29 | After a screenshot, a green frame briefly flashes over the captured area. It is drawn after the capture, so it never appears in the image. | ||
| 30 | 30 | ||||
M internal/adapters/selection/fx.go +47 -0
| @@ -21,8 +21,12 @@ const ( | |||
| 21 | 21 | warnHold = 1500 * time.Millisecond | |
| 22 | 22 | pulseSpan = 210 * time.Millisecond | |
| 23 | 23 | pulseExtra = 3.0 // logical pixels added to the border at the pulse start | |
| 24 | + | hudSteps = 4 // HUD fade length in fxSteps: 120 ms each way | |
| 24 | 25 | ) | |
| 25 | 26 | ||
| 27 | + | // hudClasses are the HUD opacities by fade level; level hudSteps is hidden. | |
| 28 | + | var hudClasses = [hudSteps]string{"o0", "o3", "o5", "o7"} | |
| 29 | + | ||
| 26 | 30 | // opacityClasses are the toast steps of style.css, from opaque to faint. | |
| 27 | 31 | var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"} | |
| 28 | 32 | ||
| @@ -53,6 +57,15 @@ type pulse struct { | |||
| 53 | 57 | on bool | |
| 54 | 58 | } | |
| 55 | 59 | ||
| 60 | + | // hudFade fades the chips, header, footer and toast out while a region is | |
| 61 | + | // dragged, and back in after. level counts steps away from fully shown. | |
| 62 | + | type hudFade struct { | |
| 63 | + | hiding bool | |
| 64 | + | from int // level when the current fade started | |
| 65 | + | start time.Time // zero until the first frame of the fade | |
| 66 | + | level int | |
| 67 | + | } | |
| 68 | + | ||
| 56 | 69 | // animator wakes its overlay every fxStep while an effect runs and sleeps | |
| 57 | 70 | // otherwise, so an idle selector does not redraw. until is written by the | |
| 58 | 71 | // overlay's owner loop and read by the animator goroutine. | |
| @@ -140,6 +153,7 @@ func modeToast(mode ports.Mode) string { | |||
| 140 | 153 | // it never allocates. | |
| 141 | 154 | func (m *model) animate(now time.Time) { | |
| 142 | 155 | m.edgeW = edge | |
| 156 | + | m.fadeHUD(now) | |
| 143 | 157 | if t := &m.toast; t.on { | |
| 144 | 158 | if t.start.IsZero() { | |
| 145 | 159 | t.start = now | |
| @@ -168,3 +182,36 @@ func (m *model) animate(now time.Time) { | |||
| 168 | 182 | } | |
| 169 | 183 | } | |
| 170 | 184 | } | |
| 185 | + | ||
| 186 | + | // fadeHUD moves the HUD fade one step per fxStep toward hidden while a region | |
| 187 | + | // is dragged and toward shown otherwise. A reversal starts from the current | |
| 188 | + | // level, so a short drag never jumps. | |
| 189 | + | func (m *model) fadeHUD(now time.Time) { | |
| 190 | + | h := &m.hud | |
| 191 | + | if hide := m.picker.Dragging(); hide != h.hiding { | |
| 192 | + | h.hiding, h.from, h.start = hide, h.level, time.Time{} | |
| 193 | + | } | |
| 194 | + | target := 0 | |
| 195 | + | if h.hiding { | |
| 196 | + | target = hudSteps | |
| 197 | + | } | |
| 198 | + | if h.level == target { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | if h.start.IsZero() { | |
| 202 | + | h.start = now | |
| 203 | + | m.anim.extend(now.Add(hudSteps * fxStep)) | |
| 204 | + | } | |
| 205 | + | n := 1 + int(now.Sub(h.start)/fxStep) | |
| 206 | + | if target > h.from { | |
| 207 | + | h.level = min(h.from+n, target) | |
| 208 | + | } else { | |
| 209 | + | h.level = max(h.from-n, target) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // hudClass is the opacity class of the HUD this frame. | |
| 214 | + | func (m *model) hudClass() string { return hudClasses[min(m.hud.level, hudSteps-1)] } | |
| 215 | + | ||
| 216 | + | // hudHidden reports a fully faded HUD, which is not laid out or drawn. | |
| 217 | + | func (m *model) hudHidden() bool { return m.hud.level >= hudSteps } | |
| @@ -21,8 +21,12 @@ const ( | |||||
| 21 | warnHold = 1500 * time.Millisecond | 21 | warnHold = 1500 * time.Millisecond | ||
| 22 | pulseSpan = 210 * time.Millisecond | 22 | pulseSpan = 210 * time.Millisecond | ||
| 23 | pulseExtra = 3.0 // logical pixels added to the border at the pulse start | 23 | pulseExtra = 3.0 // logical pixels added to the border at the pulse start | ||
| 24 | + | hudSteps = 4 // HUD fade length in fxSteps: 120 ms each way | |||
| 24 | ) | 25 | ) | ||
| 25 | 26 | ||||
| 27 | + | // hudClasses are the HUD opacities by fade level; level hudSteps is hidden. | |||
| 28 | + | var hudClasses = [hudSteps]string{"o0", "o3", "o5", "o7"} | |||
| 29 | + | ||||
| 26 | // 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. | ||
| 27 | 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"} | ||
| 28 | 32 | ||||
| @@ -53,6 +57,15 @@ type pulse struct { | |||||
| 53 | on bool | 57 | on bool | ||
| 54 | } | 58 | } | ||
| 55 | 59 | ||||
| 60 | + | // hudFade fades the chips, header, footer and toast out while a region is | |||
| 61 | + | // dragged, and back in after. level counts steps away from fully shown. | |||
| 62 | + | type hudFade struct { | |||
| 63 | + | hiding bool | |||
| 64 | + | from int // level when the current fade started | |||
| 65 | + | start time.Time // zero until the first frame of the fade | |||
| 66 | + | level int | |||
| 67 | + | } | |||
| 68 | + | ||||
| 56 | // animator wakes its overlay every fxStep while an effect runs and sleeps | 69 | // animator wakes its overlay every fxStep while an effect runs and sleeps | ||
| 57 | // otherwise, so an idle selector does not redraw. until is written by the | 70 | // otherwise, so an idle selector does not redraw. until is written by the | ||
| 58 | // overlay's owner loop and read by the animator goroutine. | 71 | // overlay's owner loop and read by the animator goroutine. | ||
| @@ -140,6 +153,7 @@ func modeToast(mode ports.Mode) string { | |||||
| 140 | // it never allocates. | 153 | // it never allocates. | ||
| 141 | func (m *model) animate(now time.Time) { | 154 | func (m *model) animate(now time.Time) { | ||
| 142 | m.edgeW = edge | 155 | m.edgeW = edge | ||
| 156 | + | m.fadeHUD(now) | |||
| 143 | if t := &m.toast; t.on { | 157 | if t := &m.toast; t.on { | ||
| 144 | if t.start.IsZero() { | 158 | if t.start.IsZero() { | ||
| 145 | t.start = now | 159 | t.start = now | ||
| @@ -168,3 +182,36 @@ func (m *model) animate(now time.Time) { | |||||
| 168 | } | 182 | } | ||
| 169 | } | 183 | } | ||
| 170 | } | 184 | } | ||
| 185 | + | ||||
| 186 | + | // fadeHUD moves the HUD fade one step per fxStep toward hidden while a region | |||
| 187 | + | // is dragged and toward shown otherwise. A reversal starts from the current | |||
| 188 | + | // level, so a short drag never jumps. | |||
| 189 | + | func (m *model) fadeHUD(now time.Time) { | |||
| 190 | + | h := &m.hud | |||
| 191 | + | if hide := m.picker.Dragging(); hide != h.hiding { | |||
| 192 | + | h.hiding, h.from, h.start = hide, h.level, time.Time{} | |||
| 193 | + | } | |||
| 194 | + | target := 0 | |||
| 195 | + | if h.hiding { | |||
| 196 | + | target = hudSteps | |||
| 197 | + | } | |||
| 198 | + | if h.level == target { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | if h.start.IsZero() { | |||
| 202 | + | h.start = now | |||
| 203 | + | m.anim.extend(now.Add(hudSteps * fxStep)) | |||
| 204 | + | } | |||
| 205 | + | n := 1 + int(now.Sub(h.start)/fxStep) | |||
| 206 | + | if target > h.from { | |||
| 207 | + | h.level = min(h.from+n, target) | |||
| 208 | + | } else { | |||
| 209 | + | h.level = max(h.from-n, target) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // hudClass is the opacity class of the HUD this frame. | |||
| 214 | + | func (m *model) hudClass() string { return hudClasses[min(m.hud.level, hudSteps-1)] } | |||
| 215 | + | ||||
| 216 | + | // hudHidden reports a fully faded HUD, which is not laid out or drawn. | |||
| 217 | + | func (m *model) hudHidden() bool { return m.hud.level >= hudSteps } | |||
M internal/adapters/selection/fx_test.go +83 -0
| @@ -189,6 +189,22 @@ func TestEffectsAllocations(t *testing.T) { | |||
| 189 | 189 | now = now.Add(time.Second) | |
| 190 | 190 | }) | |
| 191 | 191 | assert.Zero(t, n, "toasts, pulses and their frames allocate nothing") | |
| 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. | |
| 195 | + | m.input(button(100, 100, true)) | |
| 196 | + | m.input(motion(400, 300)) | |
| 197 | + | n = testing.AllocsPerRun(100, func() { | |
| 198 | + | for i := range hudSteps + 1 { | |
| 199 | + | m.animate(now.Add(time.Duration(i) * fxStep)) | |
| 200 | + | _ = m.scene() | |
| 201 | + | _ = m.hudClass() | |
| 202 | + | } | |
| 203 | + | now = now.Add(time.Second) | |
| 204 | + | }) | |
| 205 | + | assert.Zero(t, n, "HUD fade frames allocate nothing") | |
| 206 | + | m.input(motion(102, 102)) | |
| 207 | + | m.input(button(102, 102, false)) | |
| 192 | 208 | assert.False(t, m.toast.on) | |
| 193 | 209 | assert.False(t, m.pulse.on) | |
| 194 | 210 | ||
| @@ -256,3 +272,70 @@ func TestChipStatesFollowModeAndTarget(t *testing.T) { | |||
| 256 | 272 | assert.Equal(t, [2]string{"idle", "rec"}, s.modeState) | |
| 257 | 273 | assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState) | |
| 258 | 274 | } | |
| 275 | + | ||
| 276 | + | func TestDragFadesTheHUDOutAndBackIn(t *testing.T) { | |
| 277 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 278 | + | m.grid = true | |
| 279 | + | m.layoutGrid() | |
| 280 | + | t0 := time.Unix(1000, 0) | |
| 281 | + | m.input(button(100, 100, true)) | |
| 282 | + | m.animate(t0) | |
| 283 | + | assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD") | |
| 284 | + | ||
| 285 | + | m.input(motion(400, 300)) | |
| 286 | + | require.True(t, m.picker.Dragging()) | |
| 287 | + | m.input(key("Tab")) // its toast must not show during the drag | |
| 288 | + | levels := []int{} | |
| 289 | + | for i := range hudSteps + 1 { | |
| 290 | + | m.animate(t0.Add(time.Duration(i) * fxStep)) | |
| 291 | + | levels = append(levels, m.hud.level) | |
| 292 | + | } | |
| 293 | + | assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden") | |
| 294 | + | s := m.scene() | |
| 295 | + | assert.True(t, m.hudHidden()) | |
| 296 | + | assert.Equal(t, frect{}, s.header) | |
| 297 | + | assert.Equal(t, frect{}, s.footer) | |
| 298 | + | assert.Equal(t, frect{}, s.toast) | |
| 299 | + | assert.Equal(t, [2]frect{}, s.modes) | |
| 300 | + | assert.Equal(t, [3]frect{}, s.kinds) | |
| 301 | + | assert.Greater(t, s.edges[0].w, 0.0, "the border stays") | |
| 302 | + | assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays") | |
| 303 | + | assert.Greater(t, s.label.w, 0.0, "the size stays") | |
| 304 | + | assert.Greater(t, s.grid[0].h, 0.0, "the guides stay") | |
| 305 | + | ||
| 306 | + | // Releasing a too-small drag keeps picking: the HUD fades back in. | |
| 307 | + | m.input(motion(103, 103)) | |
| 308 | + | m.input(button(103, 103, false)) | |
| 309 | + | require.Equal(t, endNone, m.end) | |
| 310 | + | t1 := t0.Add(time.Second) | |
| 311 | + | levels = levels[:0] | |
| 312 | + | for i := range hudSteps + 1 { | |
| 313 | + | m.animate(t1.Add(time.Duration(i) * fxStep)) | |
| 314 | + | levels = append(levels, m.hud.level) | |
| 315 | + | } | |
| 316 | + | assert.Equal(t, []int{3, 2, 1, 0, 0}, levels) | |
| 317 | + | assert.Greater(t, m.scene().header.w, 0.0) | |
| 318 | + | assert.Equal(t, "o0", m.hudClass()) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) { | |
| 322 | + | m := newTestModel(t) | |
| 323 | + | t0 := time.Unix(1000, 0) | |
| 324 | + | m.input(button(100, 100, true)) | |
| 325 | + | m.input(motion(400, 300)) | |
| 326 | + | m.animate(t0) | |
| 327 | + | m.animate(t0.Add(fxStep)) | |
| 328 | + | require.Equal(t, 2, m.hud.level) | |
| 329 | + | m.input(motion(103, 103)) | |
| 330 | + | m.input(button(103, 103, false)) | |
| 331 | + | m.animate(t0.Add(2 * fxStep)) | |
| 332 | + | assert.Equal(t, 1, m.hud.level, "fades back from where it was") | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) { | |
| 336 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 337 | + | m.input(button(100, 50, true)) | |
| 338 | + | m.input(motion(300, 250)) | |
| 339 | + | 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) | |
| 341 | + | } | |
| @@ -189,6 +189,22 @@ func TestEffectsAllocations(t *testing.T) { | |||||
| 189 | now = now.Add(time.Second) | 189 | now = now.Add(time.Second) | ||
| 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 | + | ||||
| 193 | + | // HUD fade frames during a drag allocate nothing; the drag's own size | |||
| 194 | + | // label is measured by the picker and footer tests. | |||
| 195 | + | m.input(button(100, 100, true)) | |||
| 196 | + | m.input(motion(400, 300)) | |||
| 197 | + | n = testing.AllocsPerRun(100, func() { | |||
| 198 | + | for i := range hudSteps + 1 { | |||
| 199 | + | m.animate(now.Add(time.Duration(i) * fxStep)) | |||
| 200 | + | _ = m.scene() | |||
| 201 | + | _ = m.hudClass() | |||
| 202 | + | } | |||
| 203 | + | now = now.Add(time.Second) | |||
| 204 | + | }) | |||
| 205 | + | assert.Zero(t, n, "HUD fade frames allocate nothing") | |||
| 206 | + | m.input(motion(102, 102)) | |||
| 207 | + | m.input(button(102, 102, false)) | |||
| 192 | assert.False(t, m.toast.on) | 208 | assert.False(t, m.toast.on) | ||
| 193 | assert.False(t, m.pulse.on) | 209 | assert.False(t, m.pulse.on) | ||
| 194 | 210 | ||||
| @@ -256,3 +272,70 @@ func TestChipStatesFollowModeAndTarget(t *testing.T) { | |||||
| 256 | assert.Equal(t, [2]string{"idle", "rec"}, s.modeState) | 272 | assert.Equal(t, [2]string{"idle", "rec"}, s.modeState) | ||
| 257 | assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState) | 273 | assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState) | ||
| 258 | } | 274 | } | ||
| 275 | + | ||||
| 276 | + | func TestDragFadesTheHUDOutAndBackIn(t *testing.T) { | |||
| 277 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 278 | + | m.grid = true | |||
| 279 | + | m.layoutGrid() | |||
| 280 | + | t0 := time.Unix(1000, 0) | |||
| 281 | + | m.input(button(100, 100, true)) | |||
| 282 | + | m.animate(t0) | |||
| 283 | + | assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD") | |||
| 284 | + | ||||
| 285 | + | m.input(motion(400, 300)) | |||
| 286 | + | require.True(t, m.picker.Dragging()) | |||
| 287 | + | m.input(key("Tab")) // its toast must not show during the drag | |||
| 288 | + | levels := []int{} | |||
| 289 | + | for i := range hudSteps + 1 { | |||
| 290 | + | m.animate(t0.Add(time.Duration(i) * fxStep)) | |||
| 291 | + | levels = append(levels, m.hud.level) | |||
| 292 | + | } | |||
| 293 | + | assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden") | |||
| 294 | + | s := m.scene() | |||
| 295 | + | assert.True(t, m.hudHidden()) | |||
| 296 | + | assert.Equal(t, frect{}, s.header) | |||
| 297 | + | assert.Equal(t, frect{}, s.footer) | |||
| 298 | + | assert.Equal(t, frect{}, s.toast) | |||
| 299 | + | assert.Equal(t, [2]frect{}, s.modes) | |||
| 300 | + | assert.Equal(t, [3]frect{}, s.kinds) | |||
| 301 | + | assert.Greater(t, s.edges[0].w, 0.0, "the border stays") | |||
| 302 | + | assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays") | |||
| 303 | + | assert.Greater(t, s.label.w, 0.0, "the size stays") | |||
| 304 | + | assert.Greater(t, s.grid[0].h, 0.0, "the guides stay") | |||
| 305 | + | ||||
| 306 | + | // Releasing a too-small drag keeps picking: the HUD fades back in. | |||
| 307 | + | m.input(motion(103, 103)) | |||
| 308 | + | m.input(button(103, 103, false)) | |||
| 309 | + | require.Equal(t, endNone, m.end) | |||
| 310 | + | t1 := t0.Add(time.Second) | |||
| 311 | + | levels = levels[:0] | |||
| 312 | + | for i := range hudSteps + 1 { | |||
| 313 | + | m.animate(t1.Add(time.Duration(i) * fxStep)) | |||
| 314 | + | levels = append(levels, m.hud.level) | |||
| 315 | + | } | |||
| 316 | + | assert.Equal(t, []int{3, 2, 1, 0, 0}, levels) | |||
| 317 | + | assert.Greater(t, m.scene().header.w, 0.0) | |||
| 318 | + | assert.Equal(t, "o0", m.hudClass()) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) { | |||
| 322 | + | m := newTestModel(t) | |||
| 323 | + | t0 := time.Unix(1000, 0) | |||
| 324 | + | m.input(button(100, 100, true)) | |||
| 325 | + | m.input(motion(400, 300)) | |||
| 326 | + | m.animate(t0) | |||
| 327 | + | m.animate(t0.Add(fxStep)) | |||
| 328 | + | require.Equal(t, 2, m.hud.level) | |||
| 329 | + | m.input(motion(103, 103)) | |||
| 330 | + | m.input(button(103, 103, false)) | |||
| 331 | + | m.animate(t0.Add(2 * fxStep)) | |||
| 332 | + | assert.Equal(t, 1, m.hud.level, "fades back from where it was") | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) { | |||
| 336 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 337 | + | m.input(button(100, 50, true)) | |||
| 338 | + | m.input(motion(300, 250)) | |||
| 339 | + | 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) | |||
| 341 | + | } | |||
M internal/adapters/selection/model.go +1 -0
| @@ -68,6 +68,7 @@ type model struct { | |||
| 68 | 68 | // triggered effects so input reports them as a visible change. | |
| 69 | 69 | toast toast | |
| 70 | 70 | pulse pulse | |
| 71 | + | hud hudFade | |
| 71 | 72 | fx uint32 | |
| 72 | 73 | edgeW float64 // border width this frame; edge outside a pulse | |
| 73 | 74 | anim *animator // nil in tests: effects still advance, nothing wakes | |
| @@ -68,6 +68,7 @@ type model struct { | |||||
| 68 | // triggered effects so input reports them as a visible change. | 68 | // triggered effects so input reports them as a visible change. | ||
| 69 | toast toast | 69 | toast toast | ||
| 70 | pulse pulse | 70 | pulse pulse | ||
| 71 | + | hud hudFade | |||
| 71 | fx uint32 | 72 | fx uint32 | ||
| 72 | edgeW float64 // border width this frame; edge outside a pulse | 73 | edgeW float64 // border width this frame; edge outside a pulse | ||
| 73 | anim *animator // nil in tests: effects still advance, nothing wakes | 74 | anim *animator // nil in tests: effects still advance, nothing wakes | ||
M internal/adapters/selection/scene.go +9 -2
| @@ -172,6 +172,11 @@ func (m *model) scene() scene { | |||
| 172 | 172 | if m.label.text != "" && outline { | |
| 173 | 173 | s.label = labelRect(fr, w, h) | |
| 174 | 174 | } | |
| 175 | + | if m.hudHidden() { | |
| 176 | + | // While a region is drawn only the framing aids stay: dimming, | |
| 177 | + | // border, guides and size. The rest has faded out. | |
| 178 | + | return s | |
| 179 | + | } | |
| 175 | 180 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | |
| 176 | 181 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | |
| 177 | 182 | s.placeChips(m, w, h) | |
| @@ -186,11 +191,13 @@ func (m *model) scene() scene { | |||
| 186 | 191 | s.label.y = y | |
| 187 | 192 | } | |
| 188 | 193 | } | |
| 189 | - | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | |
| 194 | + | if !m.picker.Dragging() { // a fading HUD never pushes the drag's size around | |
| 195 | + | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | |
| 196 | + | } | |
| 190 | 197 | fw := min(footerW, w) | |
| 191 | 198 | fh := min(footerHeight(m.footerText, fw), h) | |
| 192 | 199 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | |
| 193 | - | if m.toast.on { | |
| 200 | + | if m.toast.on && m.hud.level == 0 { | |
| 194 | 201 | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | |
| 195 | 202 | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | |
| 196 | 203 | } | |
| @@ -172,6 +172,11 @@ func (m *model) scene() scene { | |||||
| 172 | if m.label.text != "" && outline { | 172 | if m.label.text != "" && outline { | ||
| 173 | s.label = labelRect(fr, w, h) | 173 | s.label = labelRect(fr, w, h) | ||
| 174 | } | 174 | } | ||
| 175 | + | if m.hudHidden() { | |||
| 176 | + | // While a region is drawn only the framing aids stay: dimming, | |||
| 177 | + | // border, guides and size. The rest has faded out. | |||
| 178 | + | return s | |||
| 179 | + | } | |||
| 175 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | 180 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | ||
| 176 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | 181 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | ||
| 177 | s.placeChips(m, w, h) | 182 | s.placeChips(m, w, h) | ||
| @@ -186,11 +191,13 @@ func (m *model) scene() scene { | |||||
| 186 | s.label.y = y | 191 | s.label.y = y | ||
| 187 | } | 192 | } | ||
| 188 | } | 193 | } | ||
| 189 | - | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | 194 | + | if !m.picker.Dragging() { // a fading HUD never pushes the drag's size around |
| 195 | + | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | |||
| 196 | + | } | |||
| 190 | fw := min(footerW, w) | 197 | fw := min(footerW, w) | ||
| 191 | fh := min(footerHeight(m.footerText, fw), h) | 198 | fh := min(footerHeight(m.footerText, fw), h) | ||
| 192 | 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} | ||
| 193 | - | if m.toast.on { | 200 | + | if m.toast.on && m.hud.level == 0 { |
| 194 | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | 201 | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | ||
| 195 | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | 202 | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | ||
| 196 | } | 203 | } | ||
M internal/adapters/selection/selection_test.go +2 -4
| @@ -443,10 +443,8 @@ func TestSceneGeometry(t *testing.T) { | |||
| 443 | 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | |
| 444 | 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | |
| 445 | 445 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | |
| 446 | - | // Above the region the label would meet the chips, so it moves below them | |
| 447 | - | // and inside the region. | |
| 448 | - | assert.Equal(t, frect{100, max(50+tagPadY, s.target.y+s.target.h+tagPadY), labelW, labelH}, s.label) | |
| 449 | - | assert.False(t, overlaps(s.label, s.target)) | |
| 446 | + | // The HUD fades out during a drag, so the label stays above the region. | |
| 447 | + | assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label) | |
| 450 | 448 | // Dim strips and region tile the output exactly. | |
| 451 | 449 | area := 200.0 * 200.0 | |
| 452 | 450 | for _, d := range s.dim { | |
| @@ -443,10 +443,8 @@ func TestSceneGeometry(t *testing.T) { | |||||
| 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | ||
| 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | ||
| 445 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | 445 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | ||
| 446 | - | // Above the region the label would meet the chips, so it moves below them | 446 | + | // The HUD fades out during a drag, so the label stays above the region. |
| 447 | - | // and inside the region. | 447 | + | assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label) |
| 448 | - | assert.Equal(t, frect{100, max(50+tagPadY, s.target.y+s.target.h+tagPadY), labelW, labelH}, s.label) | |||
| 449 | - | assert.False(t, overlaps(s.label, s.target)) | |||
| 450 | // Dim strips and region tile the output exactly. | 448 | // Dim strips and region tile the output exactly. | ||
| 451 | area := 200.0 * 200.0 | 449 | area := 200.0 * 200.0 | ||
| 452 | for _, d := range s.dim { | 450 | for _, d := range s.dim { | ||
M internal/adapters/selection/view.go +11 -9
| @@ -36,19 +36,20 @@ func view(f *nefergui.Frame, m *model) { | |||
| 36 | 36 | st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h) | |
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | - | if n := tag(st, "label", s.label); n.ok { | |
| 39 | + | hc := m.hudClass() | |
| 40 | + | if n := tag(st, "label", "o0", s.label); n.ok { | |
| 40 | 41 | n.node.Text(m.label.text) | |
| 41 | 42 | } | |
| 42 | 43 | for i, r := range s.modes { | |
| 43 | - | chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i])) | |
| 44 | + | chip(st, modeKeys[i], r, s.modeState[i], hc, badgeText(modeChips[i])) | |
| 44 | 45 | } | |
| 45 | 46 | for i, r := range s.kinds { | |
| 46 | - | chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i]) | |
| 47 | + | chip(st, kindKeys[i], r, s.kindState[i], hc, targetTexts[i]) | |
| 47 | 48 | } | |
| 48 | - | if n := tag(st, "header", s.header); n.ok { | |
| 49 | + | if n := tag(st, "header", hc, s.header); n.ok { | |
| 49 | 50 | n.node.Text(m.header) | |
| 50 | 51 | } | |
| 51 | - | if n := tag(st, "footer", s.footer); n.ok { | |
| 52 | + | if n := tag(st, "footer", hc, s.footer); n.ok { | |
| 52 | 53 | n.node.Text(m.footerText) | |
| 53 | 54 | } | |
| 54 | 55 | if r := s.toast; r.w > 0 && r.h > 0 { | |
| @@ -63,9 +64,9 @@ func view(f *nefergui.Frame, m *model) { | |||
| 63 | 64 | ||
| 64 | 65 | // chip draws one selector chip; state is shot or rec (active, in the mode | |
| 65 | 66 | // color), idle, or off (unavailable). | |
| 66 | - | func chip(st nefergui.Node, key string, r frect, state, text string) { | |
| 67 | + | func chip(st nefergui.Node, key string, r frect, state, fade, text string) { | |
| 67 | 68 | if r.w > 0 && r.h > 0 { | |
| 68 | - | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state)).Rect(r.x, r.y, r.w, r.h).Text(text) | |
| 69 | + | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state), nefergui.Class(fade)).Rect(r.x, r.y, r.w, r.h).Text(text) | |
| 69 | 70 | } | |
| 70 | 71 | } | |
| 71 | 72 | ||
| @@ -86,11 +87,12 @@ func box(st nefergui.Node, key, class string, r frect) { | |||
| 86 | 87 | } | |
| 87 | 88 | } | |
| 88 | 89 | ||
| 89 | - | func tag(st nefergui.Node, key string, r frect) tagged { | |
| 90 | + | // tag draws a text tag; fade is its opacity class (o0 is opaque). | |
| 91 | + | func tag(st nefergui.Node, key, fade string, r frect) tagged { | |
| 90 | 92 | if r.w <= 0 || r.h <= 0 { | |
| 91 | 93 | return tagged{} | |
| 92 | 94 | } | |
| 93 | - | return tagged{st.Box(nefergui.Key(key), nefergui.Class("tag")).Rect(r.x, r.y, r.w, r.h), true} | |
| 95 | + | return tagged{st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class(fade)).Rect(r.x, r.y, r.w, r.h), true} | |
| 94 | 96 | } | |
| 95 | 97 | ||
| 96 | 98 | // Static keys avoid building identity strings per frame. | |
| @@ -36,19 +36,20 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 36 | st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h) | 36 | st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h) | ||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | - | if n := tag(st, "label", s.label); n.ok { | 39 | + | hc := m.hudClass() |
| 40 | + | if n := tag(st, "label", "o0", s.label); n.ok { | |||
| 40 | n.node.Text(m.label.text) | 41 | n.node.Text(m.label.text) | ||
| 41 | } | 42 | } | ||
| 42 | for i, r := range s.modes { | 43 | for i, r := range s.modes { | ||
| 43 | - | chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i])) | 44 | + | chip(st, modeKeys[i], r, s.modeState[i], hc, badgeText(modeChips[i])) |
| 44 | } | 45 | } | ||
| 45 | for i, r := range s.kinds { | 46 | for i, r := range s.kinds { | ||
| 46 | - | chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i]) | 47 | + | chip(st, kindKeys[i], r, s.kindState[i], hc, targetTexts[i]) |
| 47 | } | 48 | } | ||
| 48 | - | if n := tag(st, "header", s.header); n.ok { | 49 | + | if n := tag(st, "header", hc, s.header); n.ok { |
| 49 | n.node.Text(m.header) | 50 | n.node.Text(m.header) | ||
| 50 | } | 51 | } | ||
| 51 | - | if n := tag(st, "footer", s.footer); n.ok { | 52 | + | if n := tag(st, "footer", hc, s.footer); n.ok { |
| 52 | n.node.Text(m.footerText) | 53 | n.node.Text(m.footerText) | ||
| 53 | } | 54 | } | ||
| 54 | if r := s.toast; r.w > 0 && r.h > 0 { | 55 | if r := s.toast; r.w > 0 && r.h > 0 { | ||
| @@ -63,9 +64,9 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 63 | 64 | ||||
| 64 | // chip draws one selector chip; state is shot or rec (active, in the mode | 65 | // chip draws one selector chip; state is shot or rec (active, in the mode | ||
| 65 | // color), idle, or off (unavailable). | 66 | // color), idle, or off (unavailable). | ||
| 66 | - | func chip(st nefergui.Node, key string, r frect, state, text string) { | 67 | + | func chip(st nefergui.Node, key string, r frect, state, fade, text string) { |
| 67 | if r.w > 0 && r.h > 0 { | 68 | if r.w > 0 && r.h > 0 { | ||
| 68 | - | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state)).Rect(r.x, r.y, r.w, r.h).Text(text) | 69 | + | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state), nefergui.Class(fade)).Rect(r.x, r.y, r.w, r.h).Text(text) |
| 69 | } | 70 | } | ||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| @@ -86,11 +87,12 @@ func box(st nefergui.Node, key, class string, r frect) { | |||||
| 86 | } | 87 | } | ||
| 87 | } | 88 | } | ||
| 88 | 89 | ||||
| 89 | - | func tag(st nefergui.Node, key string, r frect) tagged { | 90 | + | // tag draws a text tag; fade is its opacity class (o0 is opaque). |
| 91 | + | func tag(st nefergui.Node, key, fade string, r frect) tagged { | |||
| 90 | if r.w <= 0 || r.h <= 0 { | 92 | if r.w <= 0 || r.h <= 0 { | ||
| 91 | return tagged{} | 93 | return tagged{} | ||
| 92 | } | 94 | } | ||
| 93 | - | return tagged{st.Box(nefergui.Key(key), nefergui.Class("tag")).Rect(r.x, r.y, r.w, r.h), true} | 95 | + | return tagged{st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class(fade)).Rect(r.x, r.y, r.w, r.h), true} |
| 94 | } | 96 | } | ||
| 95 | 97 | ||||
| 96 | // Static keys avoid building identity strings per frame. | 98 | // Static keys avoid building identity strings per frame. | ||