detached 1218e29

Merge pull request 'fix(selection): fade the HUD out while a region is dragged' (#11) from fix/hide-hud-while-dragging into main

commit
1218e29643e18c36704f96ba19eb87c553d2d18e
author
bnema · · 2026-10-17 20:04 UTC
merge of
f8b25f6 + 6f6fbbf · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.5.1
7 files · +201 -16
M README.md +1 -1
@@ -24,7 +24,7 @@ bind.cmd+p = spawn nefercap
2424
2525 Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm.
2626
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.
2828
2929 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.
3030
@@ -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 +53 -0
@@ -21,8 +21,12 @@ const (
2121 warnHold = 1500 * time.Millisecond
2222 pulseSpan = 210 * time.Millisecond
2323 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
2425 )
2526
27+// hudOpacity indexes opacityClasses by HUD fade level; level hudSteps is hidden.
28+var hudOpacity = [hudSteps]int{0, 3, 5, 7}
29+
2630 // opacityClasses are the toast steps of style.css, from opaque to faint.
2731 var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"}
2832
@@ -53,6 +57,15 @@ type pulse struct {
5357 on bool
5458 }
5559
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+
5669 // animator wakes its overlay every fxStep while an effect runs and sleeps
5770 // otherwise, so an idle selector does not redraw. until is written by the
5871 // overlay's owner loop and read by the animator goroutine.
@@ -140,6 +153,7 @@ func modeToast(mode ports.Mode) string {
140153 // it never allocates.
141154 func (m *model) animate(now time.Time) {
142155 m.edgeW = edge
156+ m.fadeHUD(now)
143157 if t := &m.toast; t.on {
144158 if t.start.IsZero() {
145159 t.start = now
@@ -168,3 +182,42 @@ func (m *model) animate(now time.Time) {
168182 }
169183 }
170184 }
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+// 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+
216+// hudClass is the opacity class of the HUD this frame.
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())] }
221+
222+// hudHidden reports a fully faded HUD, which is not laid out or drawn.
223+func (m *model) hudHidden() bool { return m.hud.level >= hudSteps }
@@ -21,8 +21,12 @@ const (
21 warnHold = 1500 * time.Millisecond21 warnHold = 1500 * time.Millisecond
22 pulseSpan = 210 * time.Millisecond22 pulseSpan = 210 * time.Millisecond
23 pulseExtra = 3.0 // logical pixels added to the border at the pulse start23 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+// hudOpacity indexes opacityClasses by HUD fade level; level hudSteps is hidden.
28+var hudOpacity = [hudSteps]int{0, 3, 5, 7}
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 bool57 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 sleeps69 // 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 the70 // 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 = edge155 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 = now159 t.start = now
@@ -168,3 +182,42 @@ 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+// 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+
216+// hudClass is the opacity class of the HUD this frame.
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())] }
221+
222+// hudHidden reports a fully faded HUD, which is not laid out or drawn.
223+func (m *model) hudHidden() bool { return m.hud.level >= hudSteps }
M internal/adapters/selection/fx_test.go +124 -0
@@ -189,6 +189,34 @@ func TestEffectsAllocations(t *testing.T) {
189189 now = now.Add(time.Second)
190190 })
191191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192+
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.
196+ m.input(button(100, 100, true))
197+ m.input(motion(400, 300))
198+ n = testing.AllocsPerRun(100, func() {
199+ m.hud = hudFade{}
200+ m.showToast(toastMonitor, targetHold, false)
201+ for i := range hudSteps + 1 {
202+ m.animate(now.Add(time.Duration(i) * fxStep))
203+ _ = m.scene()
204+ _, _ = m.hudClass(), m.toastClass()
205+ }
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)
214+ })
215+ assert.Zero(t, n, "HUD fade frames allocate nothing")
216+ m.input(motion(102, 102))
217+ m.input(button(102, 102, false))
218+ m.animate(now.Add(time.Minute))
219+ m.animate(now.Add(2 * time.Minute))
192220 assert.False(t, m.toast.on)
193221 assert.False(t, m.pulse.on)
194222
@@ -256,3 +284,99 @@ func TestChipStatesFollowModeAndTarget(t *testing.T) {
256284 assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
257285 assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
258286 }
287+
288+func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
289+ m := toggleModel(t, ports.Screenshot, true)
290+ m.grid = true
291+ m.layoutGrid()
292+ t0 := time.Unix(1000, 0)
293+ m.input(button(100, 100, true))
294+ m.animate(t0)
295+ assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD")
296+
297+ m.input(motion(400, 300))
298+ require.True(t, m.picker.Dragging())
299+ m.input(key("Tab")) // its toast fades along with the HUD
300+ levels := []int{}
301+ for i := range hudSteps + 1 {
302+ m.animate(t0.Add(time.Duration(i) * fxStep))
303+ levels = append(levels, m.hud.level)
304+ }
305+ assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden")
306+ s := m.scene()
307+ assert.True(t, m.hudHidden())
308+ assert.Equal(t, frect{}, s.header)
309+ assert.Equal(t, frect{}, s.footer)
310+ assert.Equal(t, frect{}, s.toast)
311+ assert.Equal(t, [2]frect{}, s.modes)
312+ assert.Equal(t, [3]frect{}, s.kinds)
313+ assert.Greater(t, s.edges[0].w, 0.0, "the border stays")
314+ assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays")
315+ assert.Greater(t, s.label.w, 0.0, "the size stays")
316+ assert.Greater(t, s.grid[0].h, 0.0, "the guides stay")
317+
318+ // Releasing a too-small drag keeps picking: the HUD fades back in.
319+ m.input(motion(103, 103))
320+ m.input(button(103, 103, false))
321+ require.Equal(t, endNone, m.end)
322+ t1 := t0.Add(time.Second)
323+ levels = levels[:0]
324+ for i := range hudSteps + 1 {
325+ m.animate(t1.Add(time.Duration(i) * fxStep))
326+ levels = append(levels, m.hud.level)
327+ }
328+ assert.Equal(t, []int{3, 2, 1, 0, 0}, levels)
329+ assert.Greater(t, m.scene().header.w, 0.0)
330+ assert.Equal(t, "o0", m.hudClass())
331+}
332+
333+func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) {
334+ m := newTestModel(t)
335+ t0 := time.Unix(1000, 0)
336+ m.input(button(100, 100, true))
337+ m.input(motion(400, 300))
338+ m.animate(t0)
339+ m.animate(t0.Add(fxStep))
340+ require.Equal(t, 2, m.hud.level)
341+ m.input(motion(103, 103))
342+ m.input(button(103, 103, false))
343+ m.animate(t0.Add(2 * fxStep))
344+ assert.Equal(t, 1, m.hud.level, "fades back from where it was")
345+}
346+
347+func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
348+ m := toggleModel(t, ports.Screenshot, true)
349+ m.input(button(100, 50, true))
350+ m.input(motion(300, 250))
351+ m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
352+ assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
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+}
@@ -189,6 +189,34 @@ 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+ // 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.
196+ m.input(button(100, 100, true))
197+ m.input(motion(400, 300))
198+ n = testing.AllocsPerRun(100, func() {
199+ m.hud = hudFade{}
200+ m.showToast(toastMonitor, targetHold, false)
201+ for i := range hudSteps + 1 {
202+ m.animate(now.Add(time.Duration(i) * fxStep))
203+ _ = m.scene()
204+ _, _ = m.hudClass(), m.toastClass()
205+ }
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)
214+ })
215+ assert.Zero(t, n, "HUD fade frames allocate nothing")
216+ m.input(motion(102, 102))
217+ m.input(button(102, 102, false))
218+ m.animate(now.Add(time.Minute))
219+ m.animate(now.Add(2 * time.Minute))
192 assert.False(t, m.toast.on)220 assert.False(t, m.toast.on)
193 assert.False(t, m.pulse.on)221 assert.False(t, m.pulse.on)
194 222
@@ -256,3 +284,99 @@ func TestChipStatesFollowModeAndTarget(t *testing.T) {
256 assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)284 assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
257 assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)285 assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
258 }286 }
287+
288+func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
289+ m := toggleModel(t, ports.Screenshot, true)
290+ m.grid = true
291+ m.layoutGrid()
292+ t0 := time.Unix(1000, 0)
293+ m.input(button(100, 100, true))
294+ m.animate(t0)
295+ assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD")
296+
297+ m.input(motion(400, 300))
298+ require.True(t, m.picker.Dragging())
299+ m.input(key("Tab")) // its toast fades along with the HUD
300+ levels := []int{}
301+ for i := range hudSteps + 1 {
302+ m.animate(t0.Add(time.Duration(i) * fxStep))
303+ levels = append(levels, m.hud.level)
304+ }
305+ assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden")
306+ s := m.scene()
307+ assert.True(t, m.hudHidden())
308+ assert.Equal(t, frect{}, s.header)
309+ assert.Equal(t, frect{}, s.footer)
310+ assert.Equal(t, frect{}, s.toast)
311+ assert.Equal(t, [2]frect{}, s.modes)
312+ assert.Equal(t, [3]frect{}, s.kinds)
313+ assert.Greater(t, s.edges[0].w, 0.0, "the border stays")
314+ assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays")
315+ assert.Greater(t, s.label.w, 0.0, "the size stays")
316+ assert.Greater(t, s.grid[0].h, 0.0, "the guides stay")
317+
318+ // Releasing a too-small drag keeps picking: the HUD fades back in.
319+ m.input(motion(103, 103))
320+ m.input(button(103, 103, false))
321+ require.Equal(t, endNone, m.end)
322+ t1 := t0.Add(time.Second)
323+ levels = levels[:0]
324+ for i := range hudSteps + 1 {
325+ m.animate(t1.Add(time.Duration(i) * fxStep))
326+ levels = append(levels, m.hud.level)
327+ }
328+ assert.Equal(t, []int{3, 2, 1, 0, 0}, levels)
329+ assert.Greater(t, m.scene().header.w, 0.0)
330+ assert.Equal(t, "o0", m.hudClass())
331+}
332+
333+func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) {
334+ m := newTestModel(t)
335+ t0 := time.Unix(1000, 0)
336+ m.input(button(100, 100, true))
337+ m.input(motion(400, 300))
338+ m.animate(t0)
339+ m.animate(t0.Add(fxStep))
340+ require.Equal(t, 2, m.hud.level)
341+ m.input(motion(103, 103))
342+ m.input(button(103, 103, false))
343+ m.animate(t0.Add(2 * fxStep))
344+ assert.Equal(t, 1, m.hud.level, "fades back from where it was")
345+}
346+
347+func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
348+ m := toggleModel(t, ports.Screenshot, true)
349+ m.input(button(100, 50, true))
350+ m.input(motion(300, 250))
351+ m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
352+ assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
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/model.go +1 -0
@@ -68,6 +68,7 @@ type model struct {
6868 // triggered effects so input reports them as a visible change.
6969 toast toast
7070 pulse pulse
71+ hud hudFade
7172 fx uint32
7273 edgeW float64 // border width this frame; edge outside a pulse
7374 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 toast69 toast toast
70 pulse pulse70 pulse pulse
71+ hud hudFade
71 fx uint3272 fx uint32
72 edgeW float64 // border width this frame; edge outside a pulse73 edgeW float64 // border width this frame; edge outside a pulse
73 anim *animator // nil in tests: effects still advance, nothing wakes74 anim *animator // nil in tests: effects still advance, nothing wakes
M internal/adapters/selection/scene.go +8 -1
@@ -172,6 +172,11 @@ func (m *model) scene() scene {
172172 if m.label.text != "" && outline {
173173 s.label = labelRect(fr, w, h)
174174 }
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+ }
175180 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
176181 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
177182 s.placeChips(m, w, h)
@@ -186,7 +191,9 @@ func (m *model) scene() scene {
186191 s.label.y = y
187192 }
188193 }
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+ }
190197 fw := min(footerW, w)
191198 fh := min(footerHeight(m.footerText, fw), h)
192199 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
@@ -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,7 +191,9 @@ func (m *model) scene() scene {
186 s.label.y = y191 s.label.y = y
187 }192 }
188 }193 }
189- s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it194+ 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}
M internal/adapters/selection/selection_test.go +2 -4
@@ -443,10 +443,8 @@ func TestSceneGeometry(t *testing.T) {
443443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
444444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
445445 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)
450448 // Dim strips and region tile the output exactly.
451449 area := 200.0 * 200.0
452450 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 them446+ // 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.0449 area := 200.0 * 200.0
452 for _, d := range s.dim {450 for _, d := range s.dim {
M internal/adapters/selection/view.go +12 -10
@@ -36,19 +36,20 @@ func view(f *nefergui.Frame, m *model) {
3636 st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h)
3737 }
3838 }
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 {
4041 n.node.Text(m.label.text)
4142 }
4243 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]))
4445 }
4546 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])
4748 }
48- if n := tag(st, "header", s.header); n.ok {
49+ if n := tag(st, "header", hc, s.header); n.ok {
4950 n.node.Text(m.header)
5051 }
51- if n := tag(st, "footer", s.footer); n.ok {
52+ if n := tag(st, "footer", hc, s.footer); n.ok {
5253 n.node.Text(m.footerText)
5354 }
5455 if r := s.toast; r.w > 0 && r.h > 0 {
@@ -57,15 +58,15 @@ func view(f *nefergui.Frame, m *model) {
5758 kind = "warn"
5859 }
5960 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),
60- 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)
6162 }
6263 }
6364
6465 // chip draws one selector chip; state is shot or rec (active, in the mode
6566 // 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) {
6768 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)
6970 }
7071 }
7172
@@ -86,11 +87,12 @@ func box(st nefergui.Node, key, class string, r frect) {
8687 }
8788 }
8889
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 {
9092 if r.w <= 0 || r.h <= 0 {
9193 return tagged{}
9294 }
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}
9496 }
9597
9698 // 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 {
@@ -57,15 +58,15 @@ func view(f *nefergui.Frame, m *model) {
57 kind = "warn"58 kind = "warn"
58 }59 }
59 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),60 st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind),
60- 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)
61 }62 }
62 }63 }
63 64
64 // chip draws one selector chip; state is shot or rec (active, in the mode65 // 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.

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