detached f8b25f6

Merge pull request 'feat(selection): show mode and target chips with key toasts and a border pulse' (#10) from feat/target-mode-feedback into main

commit
f8b25f64e1b1ad6cdd0984b6fc35c4b48d476b2d
author
bnema · · 2026-10-17 18:18 UTC
merge of
76bcd84 + 06fe392 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.5.0
12 files · +703 -50
M Makefile +1 -1
@@ -48,7 +48,7 @@ arch:
4848 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
4949 # the packages holding them. Every named test must exist, so the guard cannot
5050 # pass vacuously when a frame-path test is removed or renamed.
51-PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
51+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
5252 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
5353 perf_empty :=
5454 perf_space := $(perf_empty) $(perf_empty)
@@ -48,7 +48,7 @@ arch:
48 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS48 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
49 # the packages holding them. Every named test must exist, so the guard cannot49 # the packages holding them. Every named test must exist, so the guard cannot
50 # pass vacuously when a frame-path test is removed or renamed.50 # pass vacuously when a frame-path test is removed or renamed.
51-PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations51+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations
52 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland52 PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
53 perf_empty :=53 perf_empty :=
54 perf_space := $(perf_empty) $(perf_empty)54 perf_space := $(perf_empty) $(perf_empty)
M README.md +4 -2
@@ -22,7 +22,9 @@ Bind `nefercap` to a key in your compositor. For NeferWL:
2222 bind.cmd+p = spawn nefercap
2323 ```
2424
25-Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. A badge at the top left shows the mode: `SHOT` or `REC`.
25+Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm.
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.
2628
2729 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.
2830
@@ -32,7 +34,7 @@ While recording on NeferWL, a border marks the target and a small **REC / Stop**
3234 | --- | --- |
3335 | Drag | Capture a rectangle |
3436 | Click | Capture the monitor |
35-| `W`, then Enter | Capture the current workspace |
37+| `W`, then Enter | Capture the current workspace, or the monitor (with a warning) when there is none |
3638 | `M`, then Enter | Capture the monitor |
3739 | `1`–`9` | Pick that monitor |
3840 | `R` | Back to rectangle selection |
@@ -22,7 +22,9 @@ Bind `nefercap` to a key in your compositor. For NeferWL:
22 bind.cmd+p = spawn nefercap22 bind.cmd+p = spawn nefercap
23 ```23 ```
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. A badge at the top left shows the mode: `SHOT` or `REC`.25+Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm.
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.
26 28
27 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.
28 30
@@ -32,7 +34,7 @@ While recording on NeferWL, a border marks the target and a small **REC / Stop**
32 | --- | --- |34 | --- | --- |
33 | Drag | Capture a rectangle |35 | Drag | Capture a rectangle |
34 | Click | Capture the monitor |36 | Click | Capture the monitor |
35-| `W`, then Enter | Capture the current workspace |37+| `W`, then Enter | Capture the current workspace, or the monitor (with a warning) when there is none |
36 | `M`, then Enter | Capture the monitor |38 | `M`, then Enter | Capture the monitor |
37 | `1`–`9` | Pick that monitor |39 | `1`–`9` | Pick that monitor |
38 | `R` | Back to rectangle selection |40 | `R` | Back to rectangle selection |
M internal/adapters/cli/cli.go +2 -1
@@ -75,7 +75,8 @@ Selector keys and mouse:
7575 click capture the whole monitor; after w, the current workspace
7676 r back to region selection
7777 m choose the whole monitor, Enter confirms
78- w choose the current workspace when known, click or Enter confirms
78+ w choose the current workspace, or the monitor with a warning when
79+ none is known; click or Enter confirms
7980 1..9 pick that whole monitor from any overlay
8081 g toggle full-monitor center and thirds guides (on by default)
8182 Tab switch shot <-> rec (all-in-one only)
@@ -75,7 +75,8 @@ Selector keys and mouse:
75 click capture the whole monitor; after w, the current workspace75 click capture the whole monitor; after w, the current workspace
76 r back to region selection76 r back to region selection
77 m choose the whole monitor, Enter confirms77 m choose the whole monitor, Enter confirms
78- w choose the current workspace when known, click or Enter confirms78+ w choose the current workspace, or the monitor with a warning when
79+ none is known; click or Enter confirms
79 1..9 pick that whole monitor from any overlay80 1..9 pick that whole monitor from any overlay
80 g toggle full-monitor center and thirds guides (on by default)81 g toggle full-monitor center and thirds guides (on by default)
81 Tab switch shot <-> rec (all-in-one only)82 Tab switch shot <-> rec (all-in-one only)
A internal/adapters/selection/fx.go +170 -0
@@ -0,0 +1,170 @@
1+package selection
2+
3+import (
4+ "context"
5+ "math"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/nefercap/internal/core"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Short effects confirm a key press: a toast names the new mode or target and
14+// the selection border pulses. They only exist while the selector is open, so
15+// they never reach a capture.
16+const (
17+ fxStep = 30 * time.Millisecond
18+ toastFade = 9 * fxStep // o1..o9 after the hold
19+ modeHold = 600 * time.Millisecond
20+ targetHold = 400 * time.Millisecond
21+ warnHold = 1500 * time.Millisecond
22+ pulseSpan = 210 * time.Millisecond
23+ pulseExtra = 3.0 // logical pixels added to the border at the pulse start
24+)
25+
26+// 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"}
28+
29+// Toast texts are constants so triggering one never allocates.
30+const (
31+ toastShot = "Screenshot mode"
32+ toastRec = "Record mode"
33+ toastRegion = "Region · drag to select"
34+ toastMonitor = "Monitor"
35+ toastWorkspace = "Workspace"
36+ toastNoWS = "No workspace found · using monitor"
37+)
38+
39+// toast is one centered message. start is zero until the first frame draws
40+// it, so a slow frame does not eat the hold.
41+type toast struct {
42+ text string
43+ warn bool
44+ hold time.Duration
45+ start time.Time
46+ step int // index in opacityClasses; valid while on
47+ on bool
48+}
49+
50+// pulse thickens the selection border and lets it shrink back.
51+type pulse struct {
52+ start time.Time
53+ on bool
54+}
55+
56+// 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
58+// overlay's owner loop and read by the animator goroutine.
59+type animator struct {
60+ kick chan struct{}
61+ until atomic.Int64 // unix nanoseconds
62+}
63+
64+func newAnimator() *animator { return &animator{kick: make(chan struct{}, 1)} }
65+
66+// extend keeps the overlay redrawing until t. A nil animator does nothing.
67+func (a *animator) extend(t time.Time) {
68+ if a == nil {
69+ return
70+ }
71+ if n := t.UnixNano(); n > a.until.Load() {
72+ a.until.Store(n)
73+ }
74+ select {
75+ case a.kick <- struct{}{}:
76+ default:
77+ }
78+}
79+
80+// run sends a non-blocking wake every fxStep until the effects end, then waits
81+// for the next kick. It returns when ctx ends.
82+func (a *animator) run(ctx context.Context, wake chan<- struct{}) {
83+ t := time.NewTicker(fxStep)
84+ t.Stop()
85+ defer t.Stop()
86+ for {
87+ select {
88+ case <-ctx.Done():
89+ return
90+ case <-a.kick:
91+ }
92+ t.Reset(fxStep)
93+ // One step past the end draws the frame that removes the effect.
94+ for time.Now().UnixNano() < a.until.Load()+int64(fxStep) {
95+ select {
96+ case <-ctx.Done():
97+ return
98+ case <-t.C:
99+ select {
100+ case wake <- struct{}{}:
101+ default:
102+ }
103+ }
104+ }
105+ t.Stop()
106+ }
107+}
108+
109+// showToast replaces the current toast. It never allocates.
110+func (m *model) showToast(text string, hold time.Duration, warn bool) {
111+ m.toast = toast{text: text, warn: warn, hold: hold, on: true}
112+ m.fx++
113+}
114+
115+// pulseBorder restarts the border pulse.
116+func (m *model) pulseBorder() {
117+ m.pulse = pulse{on: true}
118+ m.fx++
119+}
120+
121+// targetToast is the toast that confirms a target key.
122+func targetToast(kind core.PickKind) string {
123+ switch kind {
124+ case core.PickMonitor:
125+ return toastMonitor
126+ case core.PickWorkspace:
127+ return toastWorkspace
128+ }
129+ return toastRegion
130+}
131+
132+func modeToast(mode ports.Mode) string {
133+ if mode == ports.Record {
134+ return toastRec
135+ }
136+ return toastShot
137+}
138+
139+// animate advances the effects to now. The view calls it before every scene;
140+// it never allocates.
141+func (m *model) animate(now time.Time) {
142+ m.edgeW = edge
143+ if t := &m.toast; t.on {
144+ if t.start.IsZero() {
145+ t.start = now
146+ m.anim.extend(now.Add(t.hold + toastFade))
147+ }
148+ el := now.Sub(t.start)
149+ switch {
150+ case el >= t.hold+toastFade:
151+ t.on = false
152+ case el <= t.hold:
153+ t.step = 0
154+ default:
155+ t.step = min(1+int((el-t.hold)/fxStep), len(opacityClasses)-1)
156+ }
157+ }
158+ if p := &m.pulse; p.on {
159+ if p.start.IsZero() {
160+ p.start = now
161+ m.anim.extend(now.Add(pulseSpan))
162+ }
163+ el := now.Sub(p.start)
164+ if el >= pulseSpan {
165+ p.on = false
166+ } else {
167+ m.edgeW = edge + math.Round(pulseExtra*float64(pulseSpan-el)/float64(pulseSpan))
168+ }
169+ }
170+}
@@ -0,0 +1,170 @@
1+package selection
2+
3+import (
4+ "context"
5+ "math"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/nefercap/internal/core"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Short effects confirm a key press: a toast names the new mode or target and
14+// the selection border pulses. They only exist while the selector is open, so
15+// they never reach a capture.
16+const (
17+ fxStep = 30 * time.Millisecond
18+ toastFade = 9 * fxStep // o1..o9 after the hold
19+ modeHold = 600 * time.Millisecond
20+ targetHold = 400 * time.Millisecond
21+ warnHold = 1500 * time.Millisecond
22+ pulseSpan = 210 * time.Millisecond
23+ pulseExtra = 3.0 // logical pixels added to the border at the pulse start
24+)
25+
26+// 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"}
28+
29+// Toast texts are constants so triggering one never allocates.
30+const (
31+ toastShot = "Screenshot mode"
32+ toastRec = "Record mode"
33+ toastRegion = "Region · drag to select"
34+ toastMonitor = "Monitor"
35+ toastWorkspace = "Workspace"
36+ toastNoWS = "No workspace found · using monitor"
37+)
38+
39+// toast is one centered message. start is zero until the first frame draws
40+// it, so a slow frame does not eat the hold.
41+type toast struct {
42+ text string
43+ warn bool
44+ hold time.Duration
45+ start time.Time
46+ step int // index in opacityClasses; valid while on
47+ on bool
48+}
49+
50+// pulse thickens the selection border and lets it shrink back.
51+type pulse struct {
52+ start time.Time
53+ on bool
54+}
55+
56+// 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
58+// overlay's owner loop and read by the animator goroutine.
59+type animator struct {
60+ kick chan struct{}
61+ until atomic.Int64 // unix nanoseconds
62+}
63+
64+func newAnimator() *animator { return &animator{kick: make(chan struct{}, 1)} }
65+
66+// extend keeps the overlay redrawing until t. A nil animator does nothing.
67+func (a *animator) extend(t time.Time) {
68+ if a == nil {
69+ return
70+ }
71+ if n := t.UnixNano(); n > a.until.Load() {
72+ a.until.Store(n)
73+ }
74+ select {
75+ case a.kick <- struct{}{}:
76+ default:
77+ }
78+}
79+
80+// run sends a non-blocking wake every fxStep until the effects end, then waits
81+// for the next kick. It returns when ctx ends.
82+func (a *animator) run(ctx context.Context, wake chan<- struct{}) {
83+ t := time.NewTicker(fxStep)
84+ t.Stop()
85+ defer t.Stop()
86+ for {
87+ select {
88+ case <-ctx.Done():
89+ return
90+ case <-a.kick:
91+ }
92+ t.Reset(fxStep)
93+ // One step past the end draws the frame that removes the effect.
94+ for time.Now().UnixNano() < a.until.Load()+int64(fxStep) {
95+ select {
96+ case <-ctx.Done():
97+ return
98+ case <-t.C:
99+ select {
100+ case wake <- struct{}{}:
101+ default:
102+ }
103+ }
104+ }
105+ t.Stop()
106+ }
107+}
108+
109+// showToast replaces the current toast. It never allocates.
110+func (m *model) showToast(text string, hold time.Duration, warn bool) {
111+ m.toast = toast{text: text, warn: warn, hold: hold, on: true}
112+ m.fx++
113+}
114+
115+// pulseBorder restarts the border pulse.
116+func (m *model) pulseBorder() {
117+ m.pulse = pulse{on: true}
118+ m.fx++
119+}
120+
121+// targetToast is the toast that confirms a target key.
122+func targetToast(kind core.PickKind) string {
123+ switch kind {
124+ case core.PickMonitor:
125+ return toastMonitor
126+ case core.PickWorkspace:
127+ return toastWorkspace
128+ }
129+ return toastRegion
130+}
131+
132+func modeToast(mode ports.Mode) string {
133+ if mode == ports.Record {
134+ return toastRec
135+ }
136+ return toastShot
137+}
138+
139+// animate advances the effects to now. The view calls it before every scene;
140+// it never allocates.
141+func (m *model) animate(now time.Time) {
142+ m.edgeW = edge
143+ if t := &m.toast; t.on {
144+ if t.start.IsZero() {
145+ t.start = now
146+ m.anim.extend(now.Add(t.hold + toastFade))
147+ }
148+ el := now.Sub(t.start)
149+ switch {
150+ case el >= t.hold+toastFade:
151+ t.on = false
152+ case el <= t.hold:
153+ t.step = 0
154+ default:
155+ t.step = min(1+int((el-t.hold)/fxStep), len(opacityClasses)-1)
156+ }
157+ }
158+ if p := &m.pulse; p.on {
159+ if p.start.IsZero() {
160+ p.start = now
161+ m.anim.extend(now.Add(pulseSpan))
162+ }
163+ el := now.Sub(p.start)
164+ if el >= pulseSpan {
165+ p.on = false
166+ } else {
167+ m.edgeW = edge + math.Round(pulseExtra*float64(pulseSpan-el)/float64(pulseSpan))
168+ }
169+ }
170+}
A internal/adapters/selection/fx_test.go +258 -0
@@ -0,0 +1,258 @@
1+package selection
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/nefercap/internal/core"
13+ "github.com/bnema/nefercap/internal/ports"
14+)
15+
16+func TestTargetKeysShowToastAndPulse(t *testing.T) {
17+ m := newTestModel(t)
18+ for _, c := range []struct {
19+ key string
20+ kind core.PickKind
21+ text string
22+ }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
23+ assert.True(t, m.input(key(c.key)), c.key)
24+ assert.Equal(t, c.kind, m.picker.Kind())
25+ assert.Equal(t, c.text, m.toast.text)
26+ assert.False(t, m.toast.warn)
27+ assert.True(t, m.pulse.on)
28+ }
29+}
30+
31+func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
32+ m, _ := newBareModel()
33+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
34+ m.resize(1920, 1080, 1)
35+ m.input(key("w"))
36+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
37+ assert.Equal(t, toastWorkspace, m.toast.text)
38+ assert.False(t, m.toast.warn)
39+}
40+
41+func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
42+ m := newTestModel(t)
43+ assert.True(t, m.input(key("w")))
44+ assert.Equal(t, core.PickMonitor, m.picker.Kind())
45+ assert.Equal(t, toastNoWS, m.toast.text)
46+ assert.True(t, m.toast.warn)
47+ m.input(key("Return"))
48+ require.Equal(t, endAccepted, m.end)
49+ res, ok, err := m.sess.outcome(context.Background())
50+ require.NoError(t, err)
51+ require.True(t, ok)
52+ assert.Equal(t, core.PickMonitor, res.Kind)
53+ assert.Equal(t, uint32(7), res.OutputID)
54+}
55+
56+func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
57+ m := newTestModel(t)
58+ m.input(button(100, 100, true))
59+ m.input(motion(300, 300))
60+ require.True(t, m.picker.Dragging())
61+ for _, k := range []string{"m", "w", "r"} {
62+ m.input(key(k))
63+ assert.True(t, m.picker.Dragging())
64+ assert.False(t, m.toast.on, k)
65+ }
66+}
67+
68+func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
69+ sess, _ := testSession()
70+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
71+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
72+ a.toggle, b.toggle = true, true
73+ a.resize(1920, 1080, 1)
74+ b.resize(1280, 720, 1)
75+ a.input(key("Tab"))
76+ assert.Equal(t, toastRec, a.toast.text)
77+ b.syncMode() // what b's view does when woken
78+ assert.Equal(t, toastRec, b.toast.text)
79+ assert.True(t, b.pulse.on)
80+ a.input(key("Tab"))
81+ assert.Equal(t, toastShot, a.toast.text)
82+}
83+
84+func TestToastHoldsFadesAndEnds(t *testing.T) {
85+ m := newTestModel(t)
86+ m.input(key("m"))
87+ t0 := time.Unix(1000, 0)
88+ m.animate(t0)
89+ assert.Equal(t, 0, m.toast.step)
90+ assert.Greater(t, m.scene().toast.w, 0.0)
91+ m.animate(t0.Add(targetHold))
92+ assert.Equal(t, 0, m.toast.step, "held at full opacity")
93+ m.animate(t0.Add(targetHold + fxStep))
94+ assert.Equal(t, 2, m.toast.step)
95+ m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
96+ assert.Equal(t, len(opacityClasses)-1, m.toast.step)
97+ m.animate(t0.Add(targetHold + toastFade))
98+ assert.False(t, m.toast.on)
99+ assert.Equal(t, frect{}, m.scene().toast)
100+}
101+
102+func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
103+ m := newTestModel(t)
104+ m.input(key("m"))
105+ t0 := time.Unix(1000, 0)
106+ m.animate(t0)
107+ assert.Equal(t, edge+pulseExtra, m.edgeW)
108+ assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
109+ m.animate(t0.Add(pulseSpan / 2))
110+ assert.Greater(t, m.edgeW, edge)
111+ assert.Less(t, m.edgeW, edge+pulseExtra)
112+ m.animate(t0.Add(pulseSpan))
113+ assert.Equal(t, edge, m.edgeW)
114+ assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
115+}
116+
117+func TestPulseNeverExceedsATinyRegion(t *testing.T) {
118+ m := newTestModel(t)
119+ m.input(button(100, 100, true))
120+ m.input(motion(103, 103))
121+ m.pulseBorder()
122+ m.animate(time.Unix(1000, 0))
123+ for _, e := range m.scene().edges {
124+ assert.LessOrEqual(t, e.w, 3.0)
125+ assert.LessOrEqual(t, e.h, 3.0)
126+ }
127+}
128+
129+func TestChipsShowModeAndTarget(t *testing.T) {
130+ m := toggleModel(t, ports.Screenshot, true)
131+ s := m.scene()
132+ assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
133+ assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
134+ for i := range s.kinds {
135+ assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
136+ }
137+ assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
138+ assert.False(t, overlaps(s.badge, s.target))
139+ assert.False(t, overlaps(s.target, s.header))
140+
141+ fixed := toggleModel(t, ports.Record, false)
142+ s = fixed.scene()
143+ assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
144+ assert.Greater(t, s.modes[1].w, 0.0)
145+
146+ assert.Equal(t, "shot", modeClass(ports.Screenshot))
147+ assert.Equal(t, "rec", modeClass(ports.Record))
148+ assert.Contains(t, styleSheet, "box.chip.shot")
149+ assert.Contains(t, styleSheet, "box.chip.rec")
150+ assert.Contains(t, styleSheet, "box.edge.rec")
151+}
152+
153+func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
154+ for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
155+ m := toggleModel(t, ports.Screenshot, true)
156+ m.resize(w, 480, 1)
157+ s := m.scene()
158+ for _, r := range append(s.modes[:], s.kinds[:]...) {
159+ if r.w == 0 {
160+ continue
161+ }
162+ assert.GreaterOrEqual(t, r.x, 0.0)
163+ assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
164+ assert.LessOrEqual(t, r.y+r.h, 480.0)
165+ }
166+ if w >= 640 {
167+ assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
168+ }
169+ }
170+}
171+
172+func TestEffectsAllocations(t *testing.T) {
173+ if raceEnabled {
174+ t.Skip("the race detector adds allocations")
175+ }
176+ // Each run triggers both effects once, then draws them through the hold,
177+ // the fade, the pulse decay and the end, like the frames of one key press.
178+ m := newTestModel(t)
179+ m.input(key("w")) // falls back to the monitor: later presses keep the legend
180+ steps := [...]time.Duration{0, fxStep, pulseSpan / 2, pulseSpan, warnHold, warnHold + 4*fxStep, warnHold + toastFade}
181+ now := time.Unix(1000, 0)
182+ n := testing.AllocsPerRun(100, func() {
183+ m.input(key("w"))
184+ start := now
185+ for _, d := range steps {
186+ m.animate(start.Add(d))
187+ _ = m.scene()
188+ }
189+ now = now.Add(time.Second)
190+ })
191+ assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192+ assert.False(t, m.toast.on)
193+ assert.False(t, m.pulse.on)
194+
195+ n = testing.AllocsPerRun(100, func() {
196+ m.input(key("r"))
197+ m.input(key("m"))
198+ })
199+ t.Logf("target switch allocs/run: %v", n)
200+ // Measured on main before the effects: 3 (two legends and a size label).
201+ assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
202+}
203+
204+func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
205+ synctest.Test(t, func(t *testing.T) {
206+ a := newAnimator()
207+ wake := make(chan struct{}, 1)
208+ ctx, cancel := context.WithCancel(t.Context())
209+ done := make(chan struct{})
210+ go func() { a.run(ctx, wake); close(done) }()
211+
212+ time.Sleep(10 * fxStep)
213+ synctest.Wait()
214+ assert.Empty(t, wake, "an idle animator never wakes the overlay")
215+
216+ a.extend(time.Now().Add(2 * fxStep))
217+ time.Sleep(fxStep)
218+ synctest.Wait()
219+ require.Len(t, wake, 1, "a running effect wakes the overlay")
220+ <-wake
221+
222+ time.Sleep(5 * fxStep) // past the end and its final frame
223+ synctest.Wait()
224+ for len(wake) > 0 {
225+ <-wake
226+ }
227+ time.Sleep(10 * fxStep)
228+ synctest.Wait()
229+ assert.Empty(t, wake, "an ended effect stops waking the overlay")
230+
231+ cancel()
232+ synctest.Wait()
233+ select {
234+ case <-done:
235+ default:
236+ t.Fatal("animator did not stop with its context")
237+ }
238+ })
239+}
240+
241+func TestNilAnimatorIsSafe(t *testing.T) {
242+ var a *animator
243+ a.extend(time.Now())
244+}
245+
246+func TestChipStatesFollowModeAndTarget(t *testing.T) {
247+ assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
248+ assert.Equal(t, "REC", badgeText(ports.Record))
249+ m := toggleModel(t, ports.Screenshot, true)
250+ s := m.scene()
251+ assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
252+ assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
253+ m.input(key("m"))
254+ m.input(key("Tab"))
255+ s = m.scene()
256+ assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
257+ assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
258+}
@@ -0,0 +1,258 @@
1+package selection
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/nefercap/internal/core"
13+ "github.com/bnema/nefercap/internal/ports"
14+)
15+
16+func TestTargetKeysShowToastAndPulse(t *testing.T) {
17+ m := newTestModel(t)
18+ for _, c := range []struct {
19+ key string
20+ kind core.PickKind
21+ text string
22+ }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
23+ assert.True(t, m.input(key(c.key)), c.key)
24+ assert.Equal(t, c.kind, m.picker.Kind())
25+ assert.Equal(t, c.text, m.toast.text)
26+ assert.False(t, m.toast.warn)
27+ assert.True(t, m.pulse.on)
28+ }
29+}
30+
31+func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
32+ m, _ := newBareModel()
33+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
34+ m.resize(1920, 1080, 1)
35+ m.input(key("w"))
36+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
37+ assert.Equal(t, toastWorkspace, m.toast.text)
38+ assert.False(t, m.toast.warn)
39+}
40+
41+func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
42+ m := newTestModel(t)
43+ assert.True(t, m.input(key("w")))
44+ assert.Equal(t, core.PickMonitor, m.picker.Kind())
45+ assert.Equal(t, toastNoWS, m.toast.text)
46+ assert.True(t, m.toast.warn)
47+ m.input(key("Return"))
48+ require.Equal(t, endAccepted, m.end)
49+ res, ok, err := m.sess.outcome(context.Background())
50+ require.NoError(t, err)
51+ require.True(t, ok)
52+ assert.Equal(t, core.PickMonitor, res.Kind)
53+ assert.Equal(t, uint32(7), res.OutputID)
54+}
55+
56+func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
57+ m := newTestModel(t)
58+ m.input(button(100, 100, true))
59+ m.input(motion(300, 300))
60+ require.True(t, m.picker.Dragging())
61+ for _, k := range []string{"m", "w", "r"} {
62+ m.input(key(k))
63+ assert.True(t, m.picker.Dragging())
64+ assert.False(t, m.toast.on, k)
65+ }
66+}
67+
68+func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
69+ sess, _ := testSession()
70+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
71+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
72+ a.toggle, b.toggle = true, true
73+ a.resize(1920, 1080, 1)
74+ b.resize(1280, 720, 1)
75+ a.input(key("Tab"))
76+ assert.Equal(t, toastRec, a.toast.text)
77+ b.syncMode() // what b's view does when woken
78+ assert.Equal(t, toastRec, b.toast.text)
79+ assert.True(t, b.pulse.on)
80+ a.input(key("Tab"))
81+ assert.Equal(t, toastShot, a.toast.text)
82+}
83+
84+func TestToastHoldsFadesAndEnds(t *testing.T) {
85+ m := newTestModel(t)
86+ m.input(key("m"))
87+ t0 := time.Unix(1000, 0)
88+ m.animate(t0)
89+ assert.Equal(t, 0, m.toast.step)
90+ assert.Greater(t, m.scene().toast.w, 0.0)
91+ m.animate(t0.Add(targetHold))
92+ assert.Equal(t, 0, m.toast.step, "held at full opacity")
93+ m.animate(t0.Add(targetHold + fxStep))
94+ assert.Equal(t, 2, m.toast.step)
95+ m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
96+ assert.Equal(t, len(opacityClasses)-1, m.toast.step)
97+ m.animate(t0.Add(targetHold + toastFade))
98+ assert.False(t, m.toast.on)
99+ assert.Equal(t, frect{}, m.scene().toast)
100+}
101+
102+func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
103+ m := newTestModel(t)
104+ m.input(key("m"))
105+ t0 := time.Unix(1000, 0)
106+ m.animate(t0)
107+ assert.Equal(t, edge+pulseExtra, m.edgeW)
108+ assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
109+ m.animate(t0.Add(pulseSpan / 2))
110+ assert.Greater(t, m.edgeW, edge)
111+ assert.Less(t, m.edgeW, edge+pulseExtra)
112+ m.animate(t0.Add(pulseSpan))
113+ assert.Equal(t, edge, m.edgeW)
114+ assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
115+}
116+
117+func TestPulseNeverExceedsATinyRegion(t *testing.T) {
118+ m := newTestModel(t)
119+ m.input(button(100, 100, true))
120+ m.input(motion(103, 103))
121+ m.pulseBorder()
122+ m.animate(time.Unix(1000, 0))
123+ for _, e := range m.scene().edges {
124+ assert.LessOrEqual(t, e.w, 3.0)
125+ assert.LessOrEqual(t, e.h, 3.0)
126+ }
127+}
128+
129+func TestChipsShowModeAndTarget(t *testing.T) {
130+ m := toggleModel(t, ports.Screenshot, true)
131+ s := m.scene()
132+ assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
133+ assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
134+ for i := range s.kinds {
135+ assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
136+ }
137+ assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
138+ assert.False(t, overlaps(s.badge, s.target))
139+ assert.False(t, overlaps(s.target, s.header))
140+
141+ fixed := toggleModel(t, ports.Record, false)
142+ s = fixed.scene()
143+ assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
144+ assert.Greater(t, s.modes[1].w, 0.0)
145+
146+ assert.Equal(t, "shot", modeClass(ports.Screenshot))
147+ assert.Equal(t, "rec", modeClass(ports.Record))
148+ assert.Contains(t, styleSheet, "box.chip.shot")
149+ assert.Contains(t, styleSheet, "box.chip.rec")
150+ assert.Contains(t, styleSheet, "box.edge.rec")
151+}
152+
153+func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
154+ for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
155+ m := toggleModel(t, ports.Screenshot, true)
156+ m.resize(w, 480, 1)
157+ s := m.scene()
158+ for _, r := range append(s.modes[:], s.kinds[:]...) {
159+ if r.w == 0 {
160+ continue
161+ }
162+ assert.GreaterOrEqual(t, r.x, 0.0)
163+ assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
164+ assert.LessOrEqual(t, r.y+r.h, 480.0)
165+ }
166+ if w >= 640 {
167+ assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
168+ }
169+ }
170+}
171+
172+func TestEffectsAllocations(t *testing.T) {
173+ if raceEnabled {
174+ t.Skip("the race detector adds allocations")
175+ }
176+ // Each run triggers both effects once, then draws them through the hold,
177+ // the fade, the pulse decay and the end, like the frames of one key press.
178+ m := newTestModel(t)
179+ m.input(key("w")) // falls back to the monitor: later presses keep the legend
180+ steps := [...]time.Duration{0, fxStep, pulseSpan / 2, pulseSpan, warnHold, warnHold + 4*fxStep, warnHold + toastFade}
181+ now := time.Unix(1000, 0)
182+ n := testing.AllocsPerRun(100, func() {
183+ m.input(key("w"))
184+ start := now
185+ for _, d := range steps {
186+ m.animate(start.Add(d))
187+ _ = m.scene()
188+ }
189+ now = now.Add(time.Second)
190+ })
191+ assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192+ assert.False(t, m.toast.on)
193+ assert.False(t, m.pulse.on)
194+
195+ n = testing.AllocsPerRun(100, func() {
196+ m.input(key("r"))
197+ m.input(key("m"))
198+ })
199+ t.Logf("target switch allocs/run: %v", n)
200+ // Measured on main before the effects: 3 (two legends and a size label).
201+ assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
202+}
203+
204+func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
205+ synctest.Test(t, func(t *testing.T) {
206+ a := newAnimator()
207+ wake := make(chan struct{}, 1)
208+ ctx, cancel := context.WithCancel(t.Context())
209+ done := make(chan struct{})
210+ go func() { a.run(ctx, wake); close(done) }()
211+
212+ time.Sleep(10 * fxStep)
213+ synctest.Wait()
214+ assert.Empty(t, wake, "an idle animator never wakes the overlay")
215+
216+ a.extend(time.Now().Add(2 * fxStep))
217+ time.Sleep(fxStep)
218+ synctest.Wait()
219+ require.Len(t, wake, 1, "a running effect wakes the overlay")
220+ <-wake
221+
222+ time.Sleep(5 * fxStep) // past the end and its final frame
223+ synctest.Wait()
224+ for len(wake) > 0 {
225+ <-wake
226+ }
227+ time.Sleep(10 * fxStep)
228+ synctest.Wait()
229+ assert.Empty(t, wake, "an ended effect stops waking the overlay")
230+
231+ cancel()
232+ synctest.Wait()
233+ select {
234+ case <-done:
235+ default:
236+ t.Fatal("animator did not stop with its context")
237+ }
238+ })
239+}
240+
241+func TestNilAnimatorIsSafe(t *testing.T) {
242+ var a *animator
243+ a.extend(time.Now())
244+}
245+
246+func TestChipStatesFollowModeAndTarget(t *testing.T) {
247+ assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
248+ assert.Equal(t, "REC", badgeText(ports.Record))
249+ m := toggleModel(t, ports.Screenshot, true)
250+ s := m.scene()
251+ assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
252+ assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
253+ m.input(key("m"))
254+ m.input(key("Tab"))
255+ s = m.scene()
256+ assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
257+ assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
258+}
M internal/adapters/selection/mode_test.go +0 -6
@@ -68,12 +68,6 @@ func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
6868 assert.Equal(t, ports.VideoSettings{}, sel.Video)
6969 }
7070
71-func TestBadgeClassAndText(t *testing.T) {
72- assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
73- assert.Equal(t, "REC", badgeText(ports.Record))
74- assert.NotContains(t, styleSheet, "box.rec", "the rec badge keeps the neutral tag style")
75-}
76-
7771 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
7872 for _, w := range []int{1920, 640, 320, 200, 100, 40} {
7973 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
@@ -68,12 +68,6 @@ func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
68 assert.Equal(t, ports.VideoSettings{}, sel.Video)68 assert.Equal(t, ports.VideoSettings{}, sel.Video)
69 }69 }
70 70
71-func TestBadgeClassAndText(t *testing.T) {
72- assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
73- assert.Equal(t, "REC", badgeText(ports.Record))
74- assert.NotContains(t, styleSheet, "box.rec", "the rec badge keeps the neutral tag style")
75-}
76-
77 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {71 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
78 for _, w := range []int{1920, 640, 320, 200, 100, 40} {72 for _, w := range []int{1920, 640, 320, 200, 100, 40} {
79 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {73 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
M internal/adapters/selection/model.go +43 -4
@@ -63,6 +63,14 @@ type model struct {
6363 footerMode ports.Mode
6464 footerWorkspace bool
6565 label label
66+
67+ // Key feedback: a toast and a border pulse, advanced by animate. fx counts
68+ // triggered effects so input reports them as a visible change.
69+ toast toast
70+ pulse pulse
71+ fx uint32
72+ edgeW float64 // border width this frame; edge outside a pulse
73+ anim *animator // nil in tests: effects still advance, nothing wakes
6674 }
6775
6876 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
@@ -89,6 +97,8 @@ func modeAfter(base ports.Mode, n uint32) ports.Mode {
8997 func (m *model) syncMode() {
9098 if n := m.sess.toggles.Load(); n != m.seen {
9199 m.seen, m.mode = n, modeAfter(m.base, n)
100+ m.showToast(modeToast(m.mode), modeHold, false)
101+ m.pulseBorder()
92102 }
93103 }
94104
@@ -199,11 +209,12 @@ type visible struct {
199209 kind core.PickKind
200210 grid bool
201211 status core.PickStatus
212+ fx uint32
202213 }
203214
204215 func (m *model) snapshot() visible {
205216 r, _ := m.picker.Rect()
206- return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}
217+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx}
207218 }
208219
209220 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -242,11 +253,11 @@ func (m *model) key(name string) {
242253 case "Escape":
243254 m.picker.Cancel()
244255 case "m", "M":
245- m.picker.SelectMonitor()
256+ m.selectTarget(core.PickMonitor)
246257 case "w", "W":
247- m.picker.SelectWorkspace() // a no-op until a real workspace is supplied
258+ m.selectTarget(core.PickWorkspace)
248259 case "r", "R":
249- m.picker.SelectRegion()
260+ m.selectTarget(core.PickRegion)
250261 case "g", "G":
251262 m.grid = !m.grid
252263 case "Tab":
@@ -265,6 +276,34 @@ func (m *model) key(name string) {
265276 }
266277 }
267278
279+// selectTarget switches the picker target and confirms it with a toast and a
280+// border pulse, even when the target was already selected. W without a
281+// workspace on this output falls back to the monitor and says so. A drag in
282+// progress keeps its target and shows nothing.
283+func (m *model) selectTarget(kind core.PickKind) {
284+ if m.picker.Dragging() {
285+ return
286+ }
287+ if kind == core.PickWorkspace {
288+ if _, _, ok := m.picker.Workspace(); !ok {
289+ m.picker.SelectMonitor()
290+ m.showToast(toastNoWS, warnHold, true)
291+ m.pulseBorder()
292+ return
293+ }
294+ }
295+ switch kind {
296+ case core.PickMonitor:
297+ m.picker.SelectMonitor()
298+ case core.PickWorkspace:
299+ m.picker.SelectWorkspace()
300+ default:
301+ m.picker.SelectRegion()
302+ }
303+ m.showToast(targetToast(kind), targetHold, false)
304+ m.pulseBorder()
305+}
306+
268307 // keyName is the xkb name of the keysyms the selector handles, "" for the
269308 // others. Latin-1 keysyms are their code points. It never allocates.
270309 func keyName(sym uint32) string {
@@ -63,6 +63,14 @@ type model struct {
63 footerMode ports.Mode63 footerMode ports.Mode
64 footerWorkspace bool64 footerWorkspace bool
65 label label65 label label
66+
67+ // Key feedback: a toast and a border pulse, advanced by animate. fx counts
68+ // triggered effects so input reports them as a visible change.
69+ toast toast
70+ pulse pulse
71+ fx uint32
72+ edgeW float64 // border width this frame; edge outside a pulse
73+ anim *animator // nil in tests: effects still advance, nothing wakes
66 }74 }
67 75
68 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {76 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
@@ -89,6 +97,8 @@ func modeAfter(base ports.Mode, n uint32) ports.Mode {
89 func (m *model) syncMode() {97 func (m *model) syncMode() {
90 if n := m.sess.toggles.Load(); n != m.seen {98 if n := m.sess.toggles.Load(); n != m.seen {
91 m.seen, m.mode = n, modeAfter(m.base, n)99 m.seen, m.mode = n, modeAfter(m.base, n)
100+ m.showToast(modeToast(m.mode), modeHold, false)
101+ m.pulseBorder()
92 }102 }
93 }103 }
94 104
@@ -199,11 +209,12 @@ type visible struct {
199 kind core.PickKind209 kind core.PickKind
200 grid bool210 grid bool
201 status core.PickStatus211 status core.PickStatus
212+ fx uint32
202 }213 }
203 214
204 func (m *model) snapshot() visible {215 func (m *model) snapshot() visible {
205 r, _ := m.picker.Rect()216 r, _ := m.picker.Rect()
206- return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}217+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx}
207 }218 }
208 219
209 // input is the OnInput callback. It reports whether the drawn state changed,220 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -242,11 +253,11 @@ func (m *model) key(name string) {
242 case "Escape":253 case "Escape":
243 m.picker.Cancel()254 m.picker.Cancel()
244 case "m", "M":255 case "m", "M":
245- m.picker.SelectMonitor()256+ m.selectTarget(core.PickMonitor)
246 case "w", "W":257 case "w", "W":
247- m.picker.SelectWorkspace() // a no-op until a real workspace is supplied258+ m.selectTarget(core.PickWorkspace)
248 case "r", "R":259 case "r", "R":
249- m.picker.SelectRegion()260+ m.selectTarget(core.PickRegion)
250 case "g", "G":261 case "g", "G":
251 m.grid = !m.grid262 m.grid = !m.grid
252 case "Tab":263 case "Tab":
@@ -265,6 +276,34 @@ func (m *model) key(name string) {
265 }276 }
266 }277 }
267 278
279+// selectTarget switches the picker target and confirms it with a toast and a
280+// border pulse, even when the target was already selected. W without a
281+// workspace on this output falls back to the monitor and says so. A drag in
282+// progress keeps its target and shows nothing.
283+func (m *model) selectTarget(kind core.PickKind) {
284+ if m.picker.Dragging() {
285+ return
286+ }
287+ if kind == core.PickWorkspace {
288+ if _, _, ok := m.picker.Workspace(); !ok {
289+ m.picker.SelectMonitor()
290+ m.showToast(toastNoWS, warnHold, true)
291+ m.pulseBorder()
292+ return
293+ }
294+ }
295+ switch kind {
296+ case core.PickMonitor:
297+ m.picker.SelectMonitor()
298+ case core.PickWorkspace:
299+ m.picker.SelectWorkspace()
300+ default:
301+ m.picker.SelectRegion()
302+ }
303+ m.showToast(targetToast(kind), targetHold, false)
304+ m.pulseBorder()
305+}
306+
268 // keyName is the xkb name of the keysyms the selector handles, "" for the307 // keyName is the xkb name of the keysyms the selector handles, "" for the
269 // others. Latin-1 keysyms are their code points. It never allocates.308 // others. Latin-1 keysyms are their code points. It never allocates.
270 func keyName(sym uint32) string {309 func keyName(sym uint32) string {
M internal/adapters/selection/scene.go +143 -23
@@ -2,6 +2,7 @@ package selection
22
33 import (
44 "strings"
5+ "unicode/utf8"
56
67 "github.com/bnema/nefercap/internal/core"
78 "github.com/bnema/nefercap/internal/ports"
@@ -18,6 +19,10 @@ const (
1819 headerH = labelH
1920 edge = 1.0 // thin selection lines
2021 margin = baseFont
22+ chipGap = baseFont * 0.25 // between chips of one group
23+ groupGap = baseFont * 0.75 // between the mode and target groups
24+ toastFont = baseFont * 1.5
25+ toastH = toastFont * 2
2126 )
2227
2328 // noIndicatorHint is shown in record mode on a compositor that cannot keep a
@@ -69,8 +74,7 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica
6974 return b.String()
7075 }
7176
72-// badgeText is the always-visible mode badge. Both modes share the neutral
73-// tag style: a red badge would read as an active recording.
77+// badgeText is the label of a mode chip.
7478 func badgeText(mode ports.Mode) string {
7579 if mode == ports.Record {
7680 return "REC"
@@ -78,6 +82,22 @@ func badgeText(mode ports.Mode) string {
7882 return "SHOT"
7983 }
8084
85+// modeClass is the stylesheet class that colors the active mode: cyan for
86+// screenshots, coral red for recordings.
87+func modeClass(mode ports.Mode) string {
88+ if mode == ports.Record {
89+ return "rec"
90+ }
91+ return "shot"
92+}
93+
94+// Chip labels: the key, then the target. Indexes match targetKinds.
95+var (
96+ modeChips = [2]ports.Mode{ports.Screenshot, ports.Record}
97+ targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace}
98+ targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"}
99+)
100+
81101 // frect is a float rectangle in surface-local logical pixels, the unit
82102 // Node.Rect takes.
83103 type frect struct{ x, y, w, h float64 }
@@ -90,8 +110,16 @@ type scene struct {
90110 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
91111 label frect
92112 header frect
93- badge frect // top-left mode badge
94- footer frect
113+ badge frect // the mode chips together, top-left
114+ modes [2]frect // SHOT, REC; only the current one without Tab
115+ target frect // the target chips together
116+ kinds [3]frect // region, monitor, workspace
117+ // Chip classes: shot or rec for the active chip, idle, or off for a
118+ // workspace this output does not offer.
119+ modeState [2]string
120+ kindState [3]string
121+ toast frect
122+ footer frect
95123 }
96124
97125 // highlight returns the rectangle to outline and whether the rest is dimmed.
@@ -130,11 +158,12 @@ func (m *model) scene() scene {
130158 s.dim[0] = frect{0, 0, w, h}
131159 }
132160 if outline {
161+ e := min(max(m.edgeW, edge), fr.w/2, fr.h/2)
133162 s.edges = [4]frect{
134- {fr.x, fr.y, fr.w, edge},
135- {fr.x, fr.y + fr.h - edge, fr.w, edge},
136- {fr.x, fr.y, edge, fr.h},
137- {fr.x + fr.w - edge, fr.y, edge, fr.h},
163+ {fr.x, fr.y, fr.w, e},
164+ {fr.x, fr.y + fr.h - e, fr.w, e},
165+ {fr.x, fr.y, e, fr.h},
166+ {fr.x + fr.w - e, fr.y, e, fr.h},
138167 }
139168 }
140169 if m.grid {
@@ -145,7 +174,8 @@ func (m *model) scene() scene {
145174 }
146175 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
147176 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
148- s.placeBadge(m.mode, w, h)
177+ s.placeChips(m, w, h)
178+ s.chipStates(m)
149179 // Keep workspace dimensions clear of the output header; region and
150180 // monitor labels retain their independent placement.
151181 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -160,26 +190,111 @@ func (m *model) scene() scene {
160190 fw := min(footerW, w)
161191 fh := min(footerHeight(m.footerText, fw), h)
162192 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
193+ if m.toast.on {
194+ tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
195+ s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
196+ }
163197 return s
164198 }
165199
166-// placeBadge puts the mode badge top-left; on an output too narrow to share
167-// the row with the header it moves below it.
168-func (s *scene) placeBadge(mode ports.Mode, w, h float64) {
169- bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)
170- s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh}
171- if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {
172- s.badge.y = s.header.y + s.header.h + tagPadY
200+// placeChips puts the mode chips top-left and the target chips after them,
201+// or below them when the row would reach the output header. On an output too
202+// narrow to share the row with the header, both move below it.
203+func (s *scene) placeChips(m *model, w, h float64) {
204+ ch := min(headerH, h)
205+ x := 0.0
206+ for i, mode := range modeChips {
207+ if !m.toggle && mode != m.mode {
208+ continue
209+ }
210+ cw := headerWidth(badgeText(mode))
211+ if x > 0 {
212+ x += chipGap
213+ }
214+ s.modes[i] = frect{x, 0, cw, ch}
215+ x += cw
216+ }
217+ s.badge = frect{0, 0, x, ch}
218+ x = 0
219+ for i, text := range targetTexts {
220+ cw := headerWidth(text)
221+ if x > 0 {
222+ x += chipGap
223+ }
224+ s.kinds[i] = frect{x, 0, cw, ch}
225+ x += cw
226+ }
227+ s.target = frect{0, 0, x, ch}
228+
229+ top := min(margin, max(h-ch, 0))
230+ left := min(margin, max(w-s.badge.w, 0))
231+ row := frect{left, top, s.badge.w + groupGap + s.target.w, ch}
232+ below := overlaps(row, s.header) || row.x+row.w > w
233+ if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h {
234+ top = s.header.y + s.header.h + tagPadY
235+ row.y = top
236+ below = row.x+row.w > w
237+ }
238+ s.badge.x, s.badge.y = left, top
239+ s.target.x, s.target.y = left+s.badge.w+groupGap, top
240+ if below {
241+ s.target.x, s.target.y = left, top+ch+tagPadY
242+ }
243+ for i := range s.modes {
244+ place(&s.modes[i], s.badge, w, h)
245+ }
246+ for i := range s.kinds {
247+ place(&s.kinds[i], s.target, w, h)
248+ }
249+ s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
250+ s.target.w = min(s.target.w, max(w-s.target.x, 0))
251+}
252+
253+// chipStates colors the current mode and target with the mode class.
254+func (s *scene) chipStates(m *model) {
255+ mc := modeClass(m.mode)
256+ for i, mode := range modeChips {
257+ s.modeState[i] = "idle"
258+ if mode == m.mode {
259+ s.modeState[i] = mc
260+ }
261+ }
262+ _, _, workspace := m.picker.Workspace()
263+ for i, kind := range targetKinds {
264+ switch {
265+ case kind == m.picker.Kind():
266+ s.kindState[i] = mc
267+ case kind == core.PickWorkspace && !workspace:
268+ s.kindState[i] = "off"
269+ default:
270+ s.kindState[i] = "idle"
271+ }
173272 }
174- s.clearLabelOfBadge(frect{}, h)
175273 }
176274
177-// clearLabelOfBadge moves a size label that meets the badge below it, inside
178-// the highlighted rectangle fr when known.
275+// place moves a chip laid out from the origin into its group and clips it to
276+// the surface; a chip with no room left gets no area and is not drawn.
277+func place(c *frect, group frect, w, h float64) {
278+ if c.w == 0 {
279+ return
280+ }
281+ c.x += group.x
282+ c.y = group.y
283+ c.w = min(c.w, max(w-c.x, 0))
284+ c.h = min(c.h, max(h-c.y, 0))
285+ if c.w <= 0 || c.h <= 0 {
286+ *c = frect{}
287+ }
288+}
289+
290+// clearLabelOfBadge moves a size label that meets the chips below them,
291+// inside the highlighted rectangle fr when known.
179292 func (s *scene) clearLabelOfBadge(fr frect, h float64) {
180- if s.label.w > 0 && overlaps(s.label, s.badge) {
181- if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h {
182- s.label.y = y
293+ for _, r := range [2]frect{s.badge, s.target} {
294+ if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) {
295+ if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h {
296+ s.label.y = y
297+ }
183298 }
184299 }
185300 }
@@ -208,7 +323,12 @@ func footerHeight(text string, width float64) float64 {
208323
209324 // headerWidth is a monospace estimate of the header text plus padding.
210325 func headerWidth(text string) float64 {
211- return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX
326+ return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX
327+}
328+
329+// toastWidth is the same estimate at the toast's larger font.
330+func toastWidth(text string) float64 {
331+ return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX
212332 }
213333
214334 // labelRect places the size label above the region, or just inside it when
@@ -2,6 +2,7 @@ package selection
2 2
3 import (3 import (
4 "strings"4 "strings"
5+ "unicode/utf8"
5 6
6 "github.com/bnema/nefercap/internal/core"7 "github.com/bnema/nefercap/internal/core"
7 "github.com/bnema/nefercap/internal/ports"8 "github.com/bnema/nefercap/internal/ports"
@@ -18,6 +19,10 @@ const (
18 headerH = labelH19 headerH = labelH
19 edge = 1.0 // thin selection lines20 edge = 1.0 // thin selection lines
20 margin = baseFont21 margin = baseFont
22+ chipGap = baseFont * 0.25 // between chips of one group
23+ groupGap = baseFont * 0.75 // between the mode and target groups
24+ toastFont = baseFont * 1.5
25+ toastH = toastFont * 2
21 )26 )
22 27
23 // noIndicatorHint is shown in record mode on a compositor that cannot keep a28 // noIndicatorHint is shown in record mode on a compositor that cannot keep a
@@ -69,8 +74,7 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica
69 return b.String()74 return b.String()
70 }75 }
71 76
72-// badgeText is the always-visible mode badge. Both modes share the neutral77+// badgeText is the label of a mode chip.
73-// tag style: a red badge would read as an active recording.
74 func badgeText(mode ports.Mode) string {78 func badgeText(mode ports.Mode) string {
75 if mode == ports.Record {79 if mode == ports.Record {
76 return "REC"80 return "REC"
@@ -78,6 +82,22 @@ func badgeText(mode ports.Mode) string {
78 return "SHOT"82 return "SHOT"
79 }83 }
80 84
85+// modeClass is the stylesheet class that colors the active mode: cyan for
86+// screenshots, coral red for recordings.
87+func modeClass(mode ports.Mode) string {
88+ if mode == ports.Record {
89+ return "rec"
90+ }
91+ return "shot"
92+}
93+
94+// Chip labels: the key, then the target. Indexes match targetKinds.
95+var (
96+ modeChips = [2]ports.Mode{ports.Screenshot, ports.Record}
97+ targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace}
98+ targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"}
99+)
100+
81 // frect is a float rectangle in surface-local logical pixels, the unit101 // frect is a float rectangle in surface-local logical pixels, the unit
82 // Node.Rect takes.102 // Node.Rect takes.
83 type frect struct{ x, y, w, h float64 }103 type frect struct{ x, y, w, h float64 }
@@ -90,8 +110,16 @@ type scene struct {
90 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal110 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
91 label frect111 label frect
92 header frect112 header frect
93- badge frect // top-left mode badge113+ badge frect // the mode chips together, top-left
94- footer frect114+ modes [2]frect // SHOT, REC; only the current one without Tab
115+ target frect // the target chips together
116+ kinds [3]frect // region, monitor, workspace
117+ // Chip classes: shot or rec for the active chip, idle, or off for a
118+ // workspace this output does not offer.
119+ modeState [2]string
120+ kindState [3]string
121+ toast frect
122+ footer frect
95 }123 }
96 124
97 // highlight returns the rectangle to outline and whether the rest is dimmed.125 // highlight returns the rectangle to outline and whether the rest is dimmed.
@@ -130,11 +158,12 @@ func (m *model) scene() scene {
130 s.dim[0] = frect{0, 0, w, h}158 s.dim[0] = frect{0, 0, w, h}
131 }159 }
132 if outline {160 if outline {
161+ e := min(max(m.edgeW, edge), fr.w/2, fr.h/2)
133 s.edges = [4]frect{162 s.edges = [4]frect{
134- {fr.x, fr.y, fr.w, edge},163+ {fr.x, fr.y, fr.w, e},
135- {fr.x, fr.y + fr.h - edge, fr.w, edge},164+ {fr.x, fr.y + fr.h - e, fr.w, e},
136- {fr.x, fr.y, edge, fr.h},165+ {fr.x, fr.y, e, fr.h},
137- {fr.x + fr.w - edge, fr.y, edge, fr.h},166+ {fr.x + fr.w - e, fr.y, e, fr.h},
138 }167 }
139 }168 }
140 if m.grid {169 if m.grid {
@@ -145,7 +174,8 @@ func (m *model) scene() scene {
145 }174 }
146 hw, hh := min(headerWidth(m.header), w), min(headerH, h)175 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
147 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}176 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
148- s.placeBadge(m.mode, w, h)177+ s.placeChips(m, w, h)
178+ s.chipStates(m)
149 // Keep workspace dimensions clear of the output header; region and179 // Keep workspace dimensions clear of the output header; region and
150 // monitor labels retain their independent placement.180 // monitor labels retain their independent placement.
151 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&181 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -160,26 +190,111 @@ func (m *model) scene() scene {
160 fw := min(footerW, w)190 fw := min(footerW, w)
161 fh := min(footerHeight(m.footerText, fw), h)191 fh := min(footerHeight(m.footerText, fw), h)
162 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}192 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
193+ if m.toast.on {
194+ tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
195+ s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
196+ }
163 return s197 return s
164 }198 }
165 199
166-// placeBadge puts the mode badge top-left; on an output too narrow to share200+// placeChips puts the mode chips top-left and the target chips after them,
167-// the row with the header it moves below it.201+// or below them when the row would reach the output header. On an output too
168-func (s *scene) placeBadge(mode ports.Mode, w, h float64) {202+// narrow to share the row with the header, both move below it.
169- bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)203+func (s *scene) placeChips(m *model, w, h float64) {
170- s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh}204+ ch := min(headerH, h)
171- if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {205+ x := 0.0
172- s.badge.y = s.header.y + s.header.h + tagPadY206+ for i, mode := range modeChips {
207+ if !m.toggle && mode != m.mode {
208+ continue
209+ }
210+ cw := headerWidth(badgeText(mode))
211+ if x > 0 {
212+ x += chipGap
213+ }
214+ s.modes[i] = frect{x, 0, cw, ch}
215+ x += cw
216+ }
217+ s.badge = frect{0, 0, x, ch}
218+ x = 0
219+ for i, text := range targetTexts {
220+ cw := headerWidth(text)
221+ if x > 0 {
222+ x += chipGap
223+ }
224+ s.kinds[i] = frect{x, 0, cw, ch}
225+ x += cw
226+ }
227+ s.target = frect{0, 0, x, ch}
228+
229+ top := min(margin, max(h-ch, 0))
230+ left := min(margin, max(w-s.badge.w, 0))
231+ row := frect{left, top, s.badge.w + groupGap + s.target.w, ch}
232+ below := overlaps(row, s.header) || row.x+row.w > w
233+ if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h {
234+ top = s.header.y + s.header.h + tagPadY
235+ row.y = top
236+ below = row.x+row.w > w
237+ }
238+ s.badge.x, s.badge.y = left, top
239+ s.target.x, s.target.y = left+s.badge.w+groupGap, top
240+ if below {
241+ s.target.x, s.target.y = left, top+ch+tagPadY
242+ }
243+ for i := range s.modes {
244+ place(&s.modes[i], s.badge, w, h)
245+ }
246+ for i := range s.kinds {
247+ place(&s.kinds[i], s.target, w, h)
248+ }
249+ s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
250+ s.target.w = min(s.target.w, max(w-s.target.x, 0))
251+}
252+
253+// chipStates colors the current mode and target with the mode class.
254+func (s *scene) chipStates(m *model) {
255+ mc := modeClass(m.mode)
256+ for i, mode := range modeChips {
257+ s.modeState[i] = "idle"
258+ if mode == m.mode {
259+ s.modeState[i] = mc
260+ }
261+ }
262+ _, _, workspace := m.picker.Workspace()
263+ for i, kind := range targetKinds {
264+ switch {
265+ case kind == m.picker.Kind():
266+ s.kindState[i] = mc
267+ case kind == core.PickWorkspace && !workspace:
268+ s.kindState[i] = "off"
269+ default:
270+ s.kindState[i] = "idle"
271+ }
173 }272 }
174- s.clearLabelOfBadge(frect{}, h)
175 }273 }
176 274
177-// clearLabelOfBadge moves a size label that meets the badge below it, inside275+// place moves a chip laid out from the origin into its group and clips it to
178-// the highlighted rectangle fr when known.276+// the surface; a chip with no room left gets no area and is not drawn.
277+func place(c *frect, group frect, w, h float64) {
278+ if c.w == 0 {
279+ return
280+ }
281+ c.x += group.x
282+ c.y = group.y
283+ c.w = min(c.w, max(w-c.x, 0))
284+ c.h = min(c.h, max(h-c.y, 0))
285+ if c.w <= 0 || c.h <= 0 {
286+ *c = frect{}
287+ }
288+}
289+
290+// clearLabelOfBadge moves a size label that meets the chips below them,
291+// inside the highlighted rectangle fr when known.
179 func (s *scene) clearLabelOfBadge(fr frect, h float64) {292 func (s *scene) clearLabelOfBadge(fr frect, h float64) {
180- if s.label.w > 0 && overlaps(s.label, s.badge) {293+ for _, r := range [2]frect{s.badge, s.target} {
181- if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h {294+ if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) {
182- s.label.y = y295+ if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h {
296+ s.label.y = y
297+ }
183 }298 }
184 }299 }
185 }300 }
@@ -208,7 +323,12 @@ func footerHeight(text string, width float64) float64 {
208 323
209 // headerWidth is a monospace estimate of the header text plus padding.324 // headerWidth is a monospace estimate of the header text plus padding.
210 func headerWidth(text string) float64 {325 func headerWidth(text string) float64 {
211- return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX326+ return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX
327+}
328+
329+// toastWidth is the same estimate at the toast's larger font.
330+func toastWidth(text string) float64 {
331+ return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX
212 }332 }
213 333
214 // labelRect places the size label above the region, or just inside it when334 // labelRect places the size label above the region, or just inside it when
M internal/adapters/selection/selection_test.go +13 -7
@@ -234,8 +234,9 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) {
234234 none, _ := newBareModel()
235235 none.resize(1920, 1080, 1)
236236 none.input(key("w"))
237- none.input(key("Return"))
238- assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing")
237+ assert.Equal(t, core.PickMonitor, none.picker.Kind(), "W without workspace metadata falls back to the monitor")
238+ assert.True(t, none.toast.warn)
239+ assert.Equal(t, toastNoWS, none.toast.text)
239240
240241 m, _ := newBareModel()
241242 m.setWorkspaces(ws)
@@ -269,7 +270,9 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
269270 assert.False(t, ok, "a workspace that is not displayed is never offered")
270271 hidden.input(key("w"))
271272 hidden.input(key("Return"))
272- assert.Equal(t, endNone, hidden.end)
273+ require.Equal(t, endAccepted, hidden.end)
274+ res, _, _ := hidden.sess.outcome(context.Background())
275+ assert.Equal(t, core.PickMonitor, res.Kind, "a hidden workspace is never captured; W falls back to the monitor")
273276 }
274277
275278 func TestSelectionConversionUsesCoreTarget(t *testing.T) {
@@ -440,7 +443,10 @@ func TestSceneGeometry(t *testing.T) {
440443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
441444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
442445 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
443- assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label)
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))
444450 // Dim strips and region tile the output exactly.
445451 area := 200.0 * 200.0
446452 for _, d := range s.dim {
@@ -748,7 +754,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
748754 repeat.Repeat = true
749755 assert.False(t, m.input(repeat), "repeat")
750756 assert.False(t, m.input(key("q")), "unknown key")
751- assert.False(t, m.input(key("w")), "W without a workspace")
757+ assert.True(t, m.input(key("w")), "W without a workspace warns and falls back")
758+ m = newTestModel(t)
752759
753760 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
754761 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -760,9 +767,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
760767 m = newTestModel(t)
761768 assert.True(t, m.input(key("g")), "grid toggles")
762769 assert.True(t, m.input(key("m")), "monitor outline")
763- assert.False(t, m.input(key("m")), "already chosen")
770+ assert.True(t, m.input(key("m")), "an already chosen target is confirmed again")
764771 assert.True(t, m.input(key("r")))
765- assert.False(t, m.input(key("r")))
766772 }
767773
768774 func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {
@@ -234,8 +234,9 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) {
234 none, _ := newBareModel()234 none, _ := newBareModel()
235 none.resize(1920, 1080, 1)235 none.resize(1920, 1080, 1)
236 none.input(key("w"))236 none.input(key("w"))
237- none.input(key("Return"))237+ assert.Equal(t, core.PickMonitor, none.picker.Kind(), "W without workspace metadata falls back to the monitor")
238- assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing")238+ assert.True(t, none.toast.warn)
239+ assert.Equal(t, toastNoWS, none.toast.text)
239 240
240 m, _ := newBareModel()241 m, _ := newBareModel()
241 m.setWorkspaces(ws)242 m.setWorkspaces(ws)
@@ -269,7 +270,9 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
269 assert.False(t, ok, "a workspace that is not displayed is never offered")270 assert.False(t, ok, "a workspace that is not displayed is never offered")
270 hidden.input(key("w"))271 hidden.input(key("w"))
271 hidden.input(key("Return"))272 hidden.input(key("Return"))
272- assert.Equal(t, endNone, hidden.end)273+ require.Equal(t, endAccepted, hidden.end)
274+ res, _, _ := hidden.sess.outcome(context.Background())
275+ assert.Equal(t, core.PickMonitor, res.Kind, "a hidden workspace is never captured; W falls back to the monitor")
273 }276 }
274 277
275 func TestSelectionConversionUsesCoreTarget(t *testing.T) {278 func TestSelectionConversionUsesCoreTarget(t *testing.T) {
@@ -440,7 +443,10 @@ func TestSceneGeometry(t *testing.T) {
440 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
441 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
442 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])445 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
443- assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label)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))
444 // Dim strips and region tile the output exactly.450 // Dim strips and region tile the output exactly.
445 area := 200.0 * 200.0451 area := 200.0 * 200.0
446 for _, d := range s.dim {452 for _, d := range s.dim {
@@ -748,7 +754,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
748 repeat.Repeat = true754 repeat.Repeat = true
749 assert.False(t, m.input(repeat), "repeat")755 assert.False(t, m.input(repeat), "repeat")
750 assert.False(t, m.input(key("q")), "unknown key")756 assert.False(t, m.input(key("q")), "unknown key")
751- assert.False(t, m.input(key("w")), "W without a workspace")757+ assert.True(t, m.input(key("w")), "W without a workspace warns and falls back")
758+ m = newTestModel(t)
752 759
753 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")760 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
754 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")761 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -760,9 +767,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
760 m = newTestModel(t)767 m = newTestModel(t)
761 assert.True(t, m.input(key("g")), "grid toggles")768 assert.True(t, m.input(key("g")), "grid toggles")
762 assert.True(t, m.input(key("m")), "monitor outline")769 assert.True(t, m.input(key("m")), "monitor outline")
763- assert.False(t, m.input(key("m")), "already chosen")770+ assert.True(t, m.input(key("m")), "an already chosen target is confirmed again")
764 assert.True(t, m.input(key("r")))771 assert.True(t, m.input(key("r")))
765- assert.False(t, m.input(key("r")))
766 }772 }
767 773
768 func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {774 func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {
M internal/adapters/selection/selector.go +12 -3
@@ -169,15 +169,24 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
169169 m.setWorkspaces(s.workspaces)
170170 m.noIndicator = s.noIndicator
171171 m.backdrop = s.backdrops[outputs[i].ID]
172+ // Every overlay has a wake: Tab on another overlay and the key
173+ // effects both redraw through it.
174+ m.wake, m.anim = make(chan struct{}, 1), newAnimator()
172175 if s.toggle {
173- m.toggle, m.wake = true, make(chan struct{}, 1)
176+ m.toggle = true
174177 sess.wakes = append(sess.wakes, m.wake)
175178 }
176179 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)
177180 wg.Add(1)
178181 go func() {
179182 defer wg.Done()
180- sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m))
183+ fx, stop := context.WithCancel(child)
184+ var anim sync.WaitGroup
185+ anim.Go(func() { m.anim.run(fx, m.wake) })
186+ err := run(child, sheet, m)
187+ stop()
188+ anim.Wait()
189+ sess.runEnded(ctx, outputs[i].Name, err)
181190 }()
182191 }
183192 wg.Wait()
@@ -198,7 +207,7 @@ func run(ctx context.Context, sheet string, m *model) error {
198207 return layerui.Run(ctx, layerui.Config{
199208 Layer: layerConfig(m.outputs[m.index].Name, kb),
200209 Styles: sheet,
201- Wake: m.wake, // nil without toggle: never ready
210+ Wake: m.wake,
202211 OnResize: m.resize,
203212 OnInput: m.input,
204213 }, m, view)
@@ -169,15 +169,24 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
169 m.setWorkspaces(s.workspaces)169 m.setWorkspaces(s.workspaces)
170 m.noIndicator = s.noIndicator170 m.noIndicator = s.noIndicator
171 m.backdrop = s.backdrops[outputs[i].ID]171 m.backdrop = s.backdrops[outputs[i].ID]
172+ // Every overlay has a wake: Tab on another overlay and the key
173+ // effects both redraw through it.
174+ m.wake, m.anim = make(chan struct{}, 1), newAnimator()
172 if s.toggle {175 if s.toggle {
173- m.toggle, m.wake = true, make(chan struct{}, 1)176+ m.toggle = true
174 sess.wakes = append(sess.wakes, m.wake)177 sess.wakes = append(sess.wakes, m.wake)
175 }178 }
176 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)179 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)
177 wg.Add(1)180 wg.Add(1)
178 go func() {181 go func() {
179 defer wg.Done()182 defer wg.Done()
180- sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m))183+ fx, stop := context.WithCancel(child)
184+ var anim sync.WaitGroup
185+ anim.Go(func() { m.anim.run(fx, m.wake) })
186+ err := run(child, sheet, m)
187+ stop()
188+ anim.Wait()
189+ sess.runEnded(ctx, outputs[i].Name, err)
181 }()190 }()
182 }191 }
183 wg.Wait()192 wg.Wait()
@@ -198,7 +207,7 @@ func run(ctx context.Context, sheet string, m *model) error {
198 return layerui.Run(ctx, layerui.Config{207 return layerui.Run(ctx, layerui.Config{
199 Layer: layerConfig(m.outputs[m.index].Name, kb),208 Layer: layerConfig(m.outputs[m.index].Name, kb),
200 Styles: sheet,209 Styles: sheet,
201- Wake: m.wake, // nil without toggle: never ready210+ Wake: m.wake,
202 OnResize: m.resize,211 OnResize: m.resize,
203 OnInput: m.input,212 OnInput: m.input,
204 }, m, view)213 }, m, view)
M internal/adapters/selection/style.css +27 -0
@@ -9,6 +9,10 @@ box.backdrop { overflow: hidden; }
99 box.backdrop image { width: 100%; height: 100%; }
1010 box.dim { background-color: rgba(0, 0, 0, 0.5); }
1111 box.edge { background-color: #4fd1a5; }
12+/* Mode colors: cyan for screenshots, coral red for recordings. Amber is kept
13+ for warnings. */
14+box.edge.shot { background-color: #38bdf8; }
15+box.edge.rec { background-color: #ef5350; }
1216 box.grid { background-color: #808080; }
1317 box.tag {
1418 display: flex; flex-direction: row; justify-content: center; align-items: center;
@@ -16,3 +20,26 @@ box.tag {
1620 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
1721 font-family: monospace; font-size: 1rem; text-align: center;
1822 }
23+/* Mode and target chips: the active one takes the mode color. */
24+box.chip.idle { color: #8b949e; }
25+box.chip.off { color: #484f58; }
26+box.chip.shot { background-color: #38bdf8; color: #0d1014; }
27+box.chip.rec { background-color: #ef5350; color: #0d1014; }
28+/* Centered toast confirming a key, faded out by the o* steps. */
29+box.toast {
30+ display: flex; flex-direction: row; justify-content: center; align-items: center;
31+ box-sizing: border-box; overflow: hidden;
32+ background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
33+ font-family: monospace; font-size: 1.5rem; text-align: center;
34+}
35+box.toast.warn { color: #f5a524; border: 2px solid #f5a524; }
36+box.o0 { opacity: 1; }
37+box.o1 { opacity: 0.9; }
38+box.o2 { opacity: 0.8; }
39+box.o3 { opacity: 0.7; }
40+box.o4 { opacity: 0.6; }
41+box.o5 { opacity: 0.5; }
42+box.o6 { opacity: 0.4; }
43+box.o7 { opacity: 0.3; }
44+box.o8 { opacity: 0.2; }
45+box.o9 { opacity: 0.1; }
@@ -9,6 +9,10 @@ box.backdrop { overflow: hidden; }
9 box.backdrop image { width: 100%; height: 100%; }9 box.backdrop image { width: 100%; height: 100%; }
10 box.dim { background-color: rgba(0, 0, 0, 0.5); }10 box.dim { background-color: rgba(0, 0, 0, 0.5); }
11 box.edge { background-color: #4fd1a5; }11 box.edge { background-color: #4fd1a5; }
12+/* Mode colors: cyan for screenshots, coral red for recordings. Amber is kept
13+ for warnings. */
14+box.edge.shot { background-color: #38bdf8; }
15+box.edge.rec { background-color: #ef5350; }
12 box.grid { background-color: #808080; }16 box.grid { background-color: #808080; }
13 box.tag {17 box.tag {
14 display: flex; flex-direction: row; justify-content: center; align-items: center;18 display: flex; flex-direction: row; justify-content: center; align-items: center;
@@ -16,3 +20,26 @@ box.tag {
16 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;20 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
17 font-family: monospace; font-size: 1rem; text-align: center;21 font-family: monospace; font-size: 1rem; text-align: center;
18 }22 }
23+/* Mode and target chips: the active one takes the mode color. */
24+box.chip.idle { color: #8b949e; }
25+box.chip.off { color: #484f58; }
26+box.chip.shot { background-color: #38bdf8; color: #0d1014; }
27+box.chip.rec { background-color: #ef5350; color: #0d1014; }
28+/* Centered toast confirming a key, faded out by the o* steps. */
29+box.toast {
30+ display: flex; flex-direction: row; justify-content: center; align-items: center;
31+ box-sizing: border-box; overflow: hidden;
32+ background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
33+ font-family: monospace; font-size: 1.5rem; text-align: center;
34+}
35+box.toast.warn { color: #f5a524; border: 2px solid #f5a524; }
36+box.o0 { opacity: 1; }
37+box.o1 { opacity: 0.9; }
38+box.o2 { opacity: 0.8; }
39+box.o3 { opacity: 0.7; }
40+box.o4 { opacity: 0.6; }
41+box.o5 { opacity: 0.5; }
42+box.o6 { opacity: 0.4; }
43+box.o7 { opacity: 0.3; }
44+box.o8 { opacity: 0.2; }
45+box.o9 { opacity: 0.1; }
M internal/adapters/selection/view.go +30 -3
@@ -1,6 +1,8 @@
11 package selection
22
33 import (
4+ "time"
5+
46 "github.com/bnema/nefergui"
57
68 "github.com/bnema/nefercap/internal/ports"
@@ -13,6 +15,7 @@ import (
1315 func view(f *nefergui.Frame, m *model) {
1416 m.syncMode() // a Tab on another overlay redraws this one
1517 m.refreshFooter()
18+ m.animate(time.Now())
1619 root := f.Root(nefergui.Class("root"))
1720 st := root.Stack(nefergui.Class("scene"))
1821 if drawsBackdrop(m) {
@@ -27,14 +30,20 @@ func view(f *nefergui.Frame, m *model) {
2730 for i, r := range s.grid {
2831 box(st, gridKeys[i], "grid", r)
2932 }
33+ mc := modeClass(m.mode)
3034 for i, r := range s.edges {
31- box(st, edgeKeys[i], "edge", r)
35+ if r.w > 0 && r.h > 0 {
36+ st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h)
37+ }
3238 }
3339 if n := tag(st, "label", s.label); n.ok {
3440 n.node.Text(m.label.text)
3541 }
36- if n := tag(st, "badge", s.badge); n.ok {
37- n.node.Text(badgeText(m.mode))
42+ for i, r := range s.modes {
43+ chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i]))
44+ }
45+ for i, r := range s.kinds {
46+ chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i])
3847 }
3948 if n := tag(st, "header", s.header); n.ok {
4049 n.node.Text(m.header)
@@ -42,6 +51,22 @@ func view(f *nefergui.Frame, m *model) {
4251 if n := tag(st, "footer", s.footer); n.ok {
4352 n.node.Text(m.footerText)
4453 }
54+ if r := s.toast; r.w > 0 && r.h > 0 {
55+ kind := "info"
56+ if m.toast.warn {
57+ kind = "warn"
58+ }
59+ 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+ }
62+}
63+
64+// chip draws one selector chip; state is shot or rec (active, in the mode
65+// color), idle, or off (unavailable).
66+func chip(st nefergui.Node, key string, r frect, state, text string) {
67+ 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+ }
4570 }
4671
4772 // drawsBackdrop: a screenshot selection shows the frozen screen; a recording
@@ -73,4 +98,6 @@ var (
7398 dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"}
7499 gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"}
75100 edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"}
101+ modeKeys = [2]string{"mode-shot", "mode-rec"}
102+ kindKeys = [3]string{"kind-region", "kind-monitor", "kind-workspace"}
76103 )
@@ -1,6 +1,8 @@
1 package selection1 package selection
2 2
3 import (3 import (
4+ "time"
5+
4 "github.com/bnema/nefergui"6 "github.com/bnema/nefergui"
5 7
6 "github.com/bnema/nefercap/internal/ports"8 "github.com/bnema/nefercap/internal/ports"
@@ -13,6 +15,7 @@ import (
13 func view(f *nefergui.Frame, m *model) {15 func view(f *nefergui.Frame, m *model) {
14 m.syncMode() // a Tab on another overlay redraws this one16 m.syncMode() // a Tab on another overlay redraws this one
15 m.refreshFooter()17 m.refreshFooter()
18+ m.animate(time.Now())
16 root := f.Root(nefergui.Class("root"))19 root := f.Root(nefergui.Class("root"))
17 st := root.Stack(nefergui.Class("scene"))20 st := root.Stack(nefergui.Class("scene"))
18 if drawsBackdrop(m) {21 if drawsBackdrop(m) {
@@ -27,14 +30,20 @@ func view(f *nefergui.Frame, m *model) {
27 for i, r := range s.grid {30 for i, r := range s.grid {
28 box(st, gridKeys[i], "grid", r)31 box(st, gridKeys[i], "grid", r)
29 }32 }
33+ mc := modeClass(m.mode)
30 for i, r := range s.edges {34 for i, r := range s.edges {
31- box(st, edgeKeys[i], "edge", r)35+ if r.w > 0 && r.h > 0 {
36+ st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h)
37+ }
32 }38 }
33 if n := tag(st, "label", s.label); n.ok {39 if n := tag(st, "label", s.label); n.ok {
34 n.node.Text(m.label.text)40 n.node.Text(m.label.text)
35 }41 }
36- if n := tag(st, "badge", s.badge); n.ok {42+ for i, r := range s.modes {
37- n.node.Text(badgeText(m.mode))43+ chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i]))
44+ }
45+ for i, r := range s.kinds {
46+ chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i])
38 }47 }
39 if n := tag(st, "header", s.header); n.ok {48 if n := tag(st, "header", s.header); n.ok {
40 n.node.Text(m.header)49 n.node.Text(m.header)
@@ -42,6 +51,22 @@ func view(f *nefergui.Frame, m *model) {
42 if n := tag(st, "footer", s.footer); n.ok {51 if n := tag(st, "footer", s.footer); n.ok {
43 n.node.Text(m.footerText)52 n.node.Text(m.footerText)
44 }53 }
54+ if r := s.toast; r.w > 0 && r.h > 0 {
55+ kind := "info"
56+ if m.toast.warn {
57+ kind = "warn"
58+ }
59+ 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+ }
62+}
63+
64+// chip draws one selector chip; state is shot or rec (active, in the mode
65+// color), idle, or off (unavailable).
66+func chip(st nefergui.Node, key string, r frect, state, text string) {
67+ 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+ }
45 }70 }
46 71
47 // drawsBackdrop: a screenshot selection shows the frozen screen; a recording72 // drawsBackdrop: a screenshot selection shows the frozen screen; a recording
@@ -73,4 +98,6 @@ var (
73 dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"}98 dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"}
74 gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"}99 gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"}
75 edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"}100 edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"}
101+ modeKeys = [2]string{"mode-shot", "mode-rec"}
102+ kindKeys = [3]string{"kind-region", "kind-monitor", "kind-workspace"}
76 )103 )

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