detached e2eda52

feat(selection): show mode and target chips with key toasts and a border pulse

Chips at the top left show SHOT/REC and R Region/M Monitor/W Workspace;
the active ones and the selection border take the mode color (cyan for
screenshots, amber for recordings). Every R, M, W and Tab shows a short
centered toast and pulses the border; a per-overlay animator wakes the
view only while an effect runs. W without a workspace on the output falls
back to the monitor and shows a warning toast.
commit
e2eda52f1116fc65ee9b03e703e513bcfc4bcbd6
author
brice · · 2026-10-17 17:39 UTC
parent
76bcd84
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +693 -43
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, amber 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, amber 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 +248 -0
@@ -0,0 +1,248 @@
1+package selection
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/nefercap/internal/core"
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+func TestTargetKeysShowToastAndPulse(t *testing.T) {
16+ m := newTestModel(t)
17+ for _, c := range []struct {
18+ key string
19+ kind core.PickKind
20+ text string
21+ }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
22+ assert.True(t, m.input(key(c.key)), c.key)
23+ assert.Equal(t, c.kind, m.picker.Kind())
24+ assert.Equal(t, c.text, m.toast.text)
25+ assert.False(t, m.toast.warn)
26+ assert.True(t, m.pulse.on)
27+ }
28+}
29+
30+func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
31+ m, _ := newBareModel()
32+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
33+ m.resize(1920, 1080, 1)
34+ m.input(key("w"))
35+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
36+ assert.Equal(t, toastWorkspace, m.toast.text)
37+ assert.False(t, m.toast.warn)
38+}
39+
40+func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
41+ m := newTestModel(t)
42+ assert.True(t, m.input(key("w")))
43+ assert.Equal(t, core.PickMonitor, m.picker.Kind())
44+ assert.Equal(t, toastNoWS, m.toast.text)
45+ assert.True(t, m.toast.warn)
46+ m.input(key("Return"))
47+ require.Equal(t, endAccepted, m.end)
48+ res, ok, err := m.sess.outcome(context.Background())
49+ require.NoError(t, err)
50+ require.True(t, ok)
51+ assert.Equal(t, core.PickMonitor, res.Kind)
52+ assert.Equal(t, uint32(7), res.OutputID)
53+}
54+
55+func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
56+ m := newTestModel(t)
57+ m.input(button(100, 100, true))
58+ m.input(motion(300, 300))
59+ require.True(t, m.picker.Dragging())
60+ for _, k := range []string{"m", "w", "r"} {
61+ m.input(key(k))
62+ assert.True(t, m.picker.Dragging())
63+ assert.False(t, m.toast.on, k)
64+ }
65+}
66+
67+func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
68+ sess, _ := testSession()
69+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
70+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
71+ a.toggle, b.toggle = true, true
72+ a.resize(1920, 1080, 1)
73+ b.resize(1280, 720, 1)
74+ a.input(key("Tab"))
75+ assert.Equal(t, toastRec, a.toast.text)
76+ b.syncMode() // what b's view does when woken
77+ assert.Equal(t, toastRec, b.toast.text)
78+ assert.True(t, b.pulse.on)
79+ a.input(key("Tab"))
80+ assert.Equal(t, toastShot, a.toast.text)
81+}
82+
83+func TestToastHoldsFadesAndEnds(t *testing.T) {
84+ m := newTestModel(t)
85+ m.input(key("m"))
86+ t0 := time.Unix(1000, 0)
87+ m.animate(t0)
88+ assert.Equal(t, 0, m.toast.step)
89+ assert.Greater(t, m.scene().toast.w, 0.0)
90+ m.animate(t0.Add(targetHold))
91+ assert.Equal(t, 0, m.toast.step, "held at full opacity")
92+ m.animate(t0.Add(targetHold + fxStep))
93+ assert.Equal(t, 2, m.toast.step)
94+ m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
95+ assert.Equal(t, len(opacityClasses)-1, m.toast.step)
96+ m.animate(t0.Add(targetHold + toastFade))
97+ assert.False(t, m.toast.on)
98+ assert.Equal(t, frect{}, m.scene().toast)
99+}
100+
101+func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
102+ m := newTestModel(t)
103+ m.input(key("m"))
104+ t0 := time.Unix(1000, 0)
105+ m.animate(t0)
106+ assert.Equal(t, edge+pulseExtra, m.edgeW)
107+ assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
108+ m.animate(t0.Add(pulseSpan / 2))
109+ assert.Greater(t, m.edgeW, edge)
110+ assert.Less(t, m.edgeW, edge+pulseExtra)
111+ m.animate(t0.Add(pulseSpan))
112+ assert.Equal(t, edge, m.edgeW)
113+ assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
114+}
115+
116+func TestPulseNeverExceedsATinyRegion(t *testing.T) {
117+ m := newTestModel(t)
118+ m.input(button(100, 100, true))
119+ m.input(motion(103, 103))
120+ m.pulseBorder()
121+ m.animate(time.Unix(1000, 0))
122+ for _, e := range m.scene().edges {
123+ assert.LessOrEqual(t, e.w, 3.0)
124+ assert.LessOrEqual(t, e.h, 3.0)
125+ }
126+}
127+
128+func TestChipsShowModeAndTarget(t *testing.T) {
129+ m := toggleModel(t, ports.Screenshot, true)
130+ s := m.scene()
131+ assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
132+ assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
133+ for i := range s.kinds {
134+ assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
135+ }
136+ assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
137+ assert.False(t, overlaps(s.badge, s.target))
138+ assert.False(t, overlaps(s.target, s.header))
139+
140+ fixed := toggleModel(t, ports.Record, false)
141+ s = fixed.scene()
142+ assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
143+ assert.Greater(t, s.modes[1].w, 0.0)
144+
145+ assert.Equal(t, "shot", modeClass(ports.Screenshot))
146+ assert.Equal(t, "rec", modeClass(ports.Record))
147+ assert.Contains(t, styleSheet, "box.chip.shot")
148+ assert.Contains(t, styleSheet, "box.chip.rec")
149+ assert.Contains(t, styleSheet, "box.edge.rec")
150+}
151+
152+func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
153+ for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
154+ m := toggleModel(t, ports.Screenshot, true)
155+ m.resize(w, 480, 1)
156+ s := m.scene()
157+ for _, r := range append(s.modes[:], s.kinds[:]...) {
158+ if r.w == 0 {
159+ continue
160+ }
161+ assert.GreaterOrEqual(t, r.x, 0.0)
162+ assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
163+ assert.LessOrEqual(t, r.y+r.h, 480.0)
164+ }
165+ if w >= 640 {
166+ assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
167+ }
168+ }
169+}
170+
171+func TestEffectsAllocations(t *testing.T) {
172+ if raceEnabled {
173+ t.Skip("the race detector adds allocations")
174+ }
175+ // A target change rebuilds the footer legend once (covered by the footer
176+ // tests); confirming the same target only replays the effects.
177+ m := newTestModel(t)
178+ m.input(key("w")) // falls back to the monitor
179+ now := time.Unix(1000, 0)
180+ n := testing.AllocsPerRun(100, func() {
181+ m.input(key("m"))
182+ m.input(key("w"))
183+ now = now.Add(fxStep)
184+ m.animate(now)
185+ _ = m.scene()
186+ })
187+ assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
188+ n = testing.AllocsPerRun(100, func() {
189+ m.input(key("r"))
190+ m.input(key("m"))
191+ })
192+ t.Logf("target switch allocs/run: %v", n)
193+ // Measured on main before the effects: 3 (two legends and a size label).
194+ assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
195+}
196+
197+func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
198+ a := newAnimator()
199+ wake := make(chan struct{}, 1)
200+ ctx, cancel := context.WithCancel(context.Background())
201+ defer cancel() // a failed assertion must not leak the animator
202+ done := make(chan struct{})
203+ go func() { a.run(ctx, wake); close(done) }()
204+
205+ select {
206+ case <-wake:
207+ t.Fatal("idle animator woke the overlay")
208+ case <-time.After(3 * fxStep):
209+ }
210+ a.extend(time.Now().Add(2 * fxStep))
211+ select {
212+ case <-wake:
213+ case <-time.After(time.Second):
214+ t.Fatal("running effect did not wake the overlay")
215+ }
216+ time.Sleep(5 * fxStep)
217+ for len(wake) > 0 {
218+ <-wake
219+ }
220+ select {
221+ case <-wake:
222+ t.Fatal("ended effect kept waking the overlay")
223+ case <-time.After(3 * fxStep):
224+ }
225+ cancel()
226+ select {
227+ case <-done:
228+ case <-time.After(time.Second):
229+ t.Fatal("animator did not stop with its context")
230+ }
231+}
232+
233+func TestNilAnimatorIsSafe(t *testing.T) {
234+ var a *animator
235+ a.extend(time.Now())
236+}
237+
238+func TestChipStatesFollowModeAndTarget(t *testing.T) {
239+ m := toggleModel(t, ports.Screenshot, true)
240+ s := m.scene()
241+ assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
242+ assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
243+ m.input(key("m"))
244+ m.input(key("Tab"))
245+ s = m.scene()
246+ assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
247+ assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
248+}
@@ -0,0 +1,248 @@
1+package selection
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/nefercap/internal/core"
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+func TestTargetKeysShowToastAndPulse(t *testing.T) {
16+ m := newTestModel(t)
17+ for _, c := range []struct {
18+ key string
19+ kind core.PickKind
20+ text string
21+ }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
22+ assert.True(t, m.input(key(c.key)), c.key)
23+ assert.Equal(t, c.kind, m.picker.Kind())
24+ assert.Equal(t, c.text, m.toast.text)
25+ assert.False(t, m.toast.warn)
26+ assert.True(t, m.pulse.on)
27+ }
28+}
29+
30+func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
31+ m, _ := newBareModel()
32+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
33+ m.resize(1920, 1080, 1)
34+ m.input(key("w"))
35+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
36+ assert.Equal(t, toastWorkspace, m.toast.text)
37+ assert.False(t, m.toast.warn)
38+}
39+
40+func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
41+ m := newTestModel(t)
42+ assert.True(t, m.input(key("w")))
43+ assert.Equal(t, core.PickMonitor, m.picker.Kind())
44+ assert.Equal(t, toastNoWS, m.toast.text)
45+ assert.True(t, m.toast.warn)
46+ m.input(key("Return"))
47+ require.Equal(t, endAccepted, m.end)
48+ res, ok, err := m.sess.outcome(context.Background())
49+ require.NoError(t, err)
50+ require.True(t, ok)
51+ assert.Equal(t, core.PickMonitor, res.Kind)
52+ assert.Equal(t, uint32(7), res.OutputID)
53+}
54+
55+func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
56+ m := newTestModel(t)
57+ m.input(button(100, 100, true))
58+ m.input(motion(300, 300))
59+ require.True(t, m.picker.Dragging())
60+ for _, k := range []string{"m", "w", "r"} {
61+ m.input(key(k))
62+ assert.True(t, m.picker.Dragging())
63+ assert.False(t, m.toast.on, k)
64+ }
65+}
66+
67+func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
68+ sess, _ := testSession()
69+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
70+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
71+ a.toggle, b.toggle = true, true
72+ a.resize(1920, 1080, 1)
73+ b.resize(1280, 720, 1)
74+ a.input(key("Tab"))
75+ assert.Equal(t, toastRec, a.toast.text)
76+ b.syncMode() // what b's view does when woken
77+ assert.Equal(t, toastRec, b.toast.text)
78+ assert.True(t, b.pulse.on)
79+ a.input(key("Tab"))
80+ assert.Equal(t, toastShot, a.toast.text)
81+}
82+
83+func TestToastHoldsFadesAndEnds(t *testing.T) {
84+ m := newTestModel(t)
85+ m.input(key("m"))
86+ t0 := time.Unix(1000, 0)
87+ m.animate(t0)
88+ assert.Equal(t, 0, m.toast.step)
89+ assert.Greater(t, m.scene().toast.w, 0.0)
90+ m.animate(t0.Add(targetHold))
91+ assert.Equal(t, 0, m.toast.step, "held at full opacity")
92+ m.animate(t0.Add(targetHold + fxStep))
93+ assert.Equal(t, 2, m.toast.step)
94+ m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
95+ assert.Equal(t, len(opacityClasses)-1, m.toast.step)
96+ m.animate(t0.Add(targetHold + toastFade))
97+ assert.False(t, m.toast.on)
98+ assert.Equal(t, frect{}, m.scene().toast)
99+}
100+
101+func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
102+ m := newTestModel(t)
103+ m.input(key("m"))
104+ t0 := time.Unix(1000, 0)
105+ m.animate(t0)
106+ assert.Equal(t, edge+pulseExtra, m.edgeW)
107+ assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
108+ m.animate(t0.Add(pulseSpan / 2))
109+ assert.Greater(t, m.edgeW, edge)
110+ assert.Less(t, m.edgeW, edge+pulseExtra)
111+ m.animate(t0.Add(pulseSpan))
112+ assert.Equal(t, edge, m.edgeW)
113+ assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
114+}
115+
116+func TestPulseNeverExceedsATinyRegion(t *testing.T) {
117+ m := newTestModel(t)
118+ m.input(button(100, 100, true))
119+ m.input(motion(103, 103))
120+ m.pulseBorder()
121+ m.animate(time.Unix(1000, 0))
122+ for _, e := range m.scene().edges {
123+ assert.LessOrEqual(t, e.w, 3.0)
124+ assert.LessOrEqual(t, e.h, 3.0)
125+ }
126+}
127+
128+func TestChipsShowModeAndTarget(t *testing.T) {
129+ m := toggleModel(t, ports.Screenshot, true)
130+ s := m.scene()
131+ assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
132+ assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
133+ for i := range s.kinds {
134+ assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
135+ }
136+ assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
137+ assert.False(t, overlaps(s.badge, s.target))
138+ assert.False(t, overlaps(s.target, s.header))
139+
140+ fixed := toggleModel(t, ports.Record, false)
141+ s = fixed.scene()
142+ assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
143+ assert.Greater(t, s.modes[1].w, 0.0)
144+
145+ assert.Equal(t, "shot", modeClass(ports.Screenshot))
146+ assert.Equal(t, "rec", modeClass(ports.Record))
147+ assert.Contains(t, styleSheet, "box.chip.shot")
148+ assert.Contains(t, styleSheet, "box.chip.rec")
149+ assert.Contains(t, styleSheet, "box.edge.rec")
150+}
151+
152+func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
153+ for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
154+ m := toggleModel(t, ports.Screenshot, true)
155+ m.resize(w, 480, 1)
156+ s := m.scene()
157+ for _, r := range append(s.modes[:], s.kinds[:]...) {
158+ if r.w == 0 {
159+ continue
160+ }
161+ assert.GreaterOrEqual(t, r.x, 0.0)
162+ assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
163+ assert.LessOrEqual(t, r.y+r.h, 480.0)
164+ }
165+ if w >= 640 {
166+ assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
167+ }
168+ }
169+}
170+
171+func TestEffectsAllocations(t *testing.T) {
172+ if raceEnabled {
173+ t.Skip("the race detector adds allocations")
174+ }
175+ // A target change rebuilds the footer legend once (covered by the footer
176+ // tests); confirming the same target only replays the effects.
177+ m := newTestModel(t)
178+ m.input(key("w")) // falls back to the monitor
179+ now := time.Unix(1000, 0)
180+ n := testing.AllocsPerRun(100, func() {
181+ m.input(key("m"))
182+ m.input(key("w"))
183+ now = now.Add(fxStep)
184+ m.animate(now)
185+ _ = m.scene()
186+ })
187+ assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
188+ n = testing.AllocsPerRun(100, func() {
189+ m.input(key("r"))
190+ m.input(key("m"))
191+ })
192+ t.Logf("target switch allocs/run: %v", n)
193+ // Measured on main before the effects: 3 (two legends and a size label).
194+ assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
195+}
196+
197+func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
198+ a := newAnimator()
199+ wake := make(chan struct{}, 1)
200+ ctx, cancel := context.WithCancel(context.Background())
201+ defer cancel() // a failed assertion must not leak the animator
202+ done := make(chan struct{})
203+ go func() { a.run(ctx, wake); close(done) }()
204+
205+ select {
206+ case <-wake:
207+ t.Fatal("idle animator woke the overlay")
208+ case <-time.After(3 * fxStep):
209+ }
210+ a.extend(time.Now().Add(2 * fxStep))
211+ select {
212+ case <-wake:
213+ case <-time.After(time.Second):
214+ t.Fatal("running effect did not wake the overlay")
215+ }
216+ time.Sleep(5 * fxStep)
217+ for len(wake) > 0 {
218+ <-wake
219+ }
220+ select {
221+ case <-wake:
222+ t.Fatal("ended effect kept waking the overlay")
223+ case <-time.After(3 * fxStep):
224+ }
225+ cancel()
226+ select {
227+ case <-done:
228+ case <-time.After(time.Second):
229+ t.Fatal("animator did not stop with its context")
230+ }
231+}
232+
233+func TestNilAnimatorIsSafe(t *testing.T) {
234+ var a *animator
235+ a.extend(time.Now())
236+}
237+
238+func TestChipStatesFollowModeAndTarget(t *testing.T) {
239+ m := toggleModel(t, ports.Screenshot, true)
240+ s := m.scene()
241+ assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
242+ assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
243+ m.input(key("m"))
244+ m.input(key("Tab"))
245+ s = m.scene()
246+ assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
247+ assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
248+}
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 +144 -22
@@ -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,8 @@ 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. No mode is red: red would read as
78+// an active recording.
7479 func badgeText(mode ports.Mode) string {
7580 if mode == ports.Record {
7681 return "REC"
@@ -78,6 +83,22 @@ func badgeText(mode ports.Mode) string {
7883 return "SHOT"
7984 }
8085
86+// modeClass is the stylesheet class that colors the active mode: cyan for
87+// screenshots, amber for recordings.
88+func modeClass(mode ports.Mode) string {
89+ if mode == ports.Record {
90+ return "rec"
91+ }
92+ return "shot"
93+}
94+
95+// Chip labels: the key, then the target. Indexes match targetKinds.
96+var (
97+ modeChips = [2]ports.Mode{ports.Screenshot, ports.Record}
98+ targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace}
99+ targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"}
100+)
101+
81102 // frect is a float rectangle in surface-local logical pixels, the unit
82103 // Node.Rect takes.
83104 type frect struct{ x, y, w, h float64 }
@@ -90,8 +111,16 @@ type scene struct {
90111 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
91112 label frect
92113 header frect
93- badge frect // top-left mode badge
94- footer frect
114+ badge frect // the mode chips together, top-left
115+ modes [2]frect // SHOT, REC; only the current one without Tab
116+ target frect // the target chips together
117+ kinds [3]frect // region, monitor, workspace
118+ // Chip classes: shot or rec for the active chip, idle, or off for a
119+ // workspace this output does not offer.
120+ modeState [2]string
121+ kindState [3]string
122+ toast frect
123+ footer frect
95124 }
96125
97126 // highlight returns the rectangle to outline and whether the rest is dimmed.
@@ -130,11 +159,12 @@ func (m *model) scene() scene {
130159 s.dim[0] = frect{0, 0, w, h}
131160 }
132161 if outline {
162+ e := min(max(m.edgeW, edge), fr.w/2, fr.h/2)
133163 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},
164+ {fr.x, fr.y, fr.w, e},
165+ {fr.x, fr.y + fr.h - e, fr.w, e},
166+ {fr.x, fr.y, e, fr.h},
167+ {fr.x + fr.w - e, fr.y, e, fr.h},
138168 }
139169 }
140170 if m.grid {
@@ -145,7 +175,8 @@ func (m *model) scene() scene {
145175 }
146176 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
147177 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
148- s.placeBadge(m.mode, w, h)
178+ s.placeChips(m, w, h)
179+ s.chipStates(m)
149180 // Keep workspace dimensions clear of the output header; region and
150181 // monitor labels retain their independent placement.
151182 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -160,26 +191,112 @@ func (m *model) scene() scene {
160191 fw := min(footerW, w)
161192 fh := min(footerHeight(m.footerText, fw), h)
162193 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
194+ if m.toast.on {
195+ tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
196+ s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
197+ }
163198 return s
164199 }
165200
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
201+// placeChips puts the mode chips top-left and the target chips after them,
202+// or below them when the row would reach the output header. On an output too
203+// narrow to share the row with the header, both move below it.
204+func (s *scene) placeChips(m *model, w, h float64) {
205+ ch := min(headerH, h)
206+ x := 0.0
207+ for i, mode := range modeChips {
208+ if !m.toggle && mode != m.mode {
209+ continue
210+ }
211+ cw := headerWidth(badgeText(mode))
212+ if x > 0 {
213+ x += chipGap
214+ }
215+ s.modes[i] = frect{x, 0, cw, ch}
216+ x += cw
217+ }
218+ s.badge = frect{0, 0, x, ch}
219+ x = 0
220+ for i, text := range targetTexts {
221+ cw := headerWidth(text)
222+ if x > 0 {
223+ x += chipGap
224+ }
225+ s.kinds[i] = frect{x, 0, cw, ch}
226+ x += cw
227+ }
228+ s.target = frect{0, 0, x, ch}
229+
230+ top := min(margin, max(h-ch, 0))
231+ left := min(margin, max(w-s.badge.w, 0))
232+ row := frect{left, top, s.badge.w + groupGap + s.target.w, ch}
233+ below := overlaps(row, s.header) || row.x+row.w > w
234+ if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h {
235+ top = s.header.y + s.header.h + tagPadY
236+ row.y = top
237+ below = row.x+row.w > w
238+ }
239+ s.badge.x, s.badge.y = left, top
240+ s.target.x, s.target.y = left+s.badge.w+groupGap, top
241+ if below {
242+ s.target.x, s.target.y = left, top+ch+tagPadY
243+ }
244+ for i := range s.modes {
245+ place(&s.modes[i], s.badge, w, h)
173246 }
247+ for i := range s.kinds {
248+ place(&s.kinds[i], s.target, w, h)
249+ }
250+ s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
251+ s.target.w = min(s.target.w, max(w-s.target.x, 0))
174252 s.clearLabelOfBadge(frect{}, h)
175253 }
176254
177-// clearLabelOfBadge moves a size label that meets the badge below it, inside
178-// the highlighted rectangle fr when known.
255+// chipStates colors the current mode and target with the mode class.
256+func (s *scene) chipStates(m *model) {
257+ mc := modeClass(m.mode)
258+ for i, mode := range modeChips {
259+ s.modeState[i] = "idle"
260+ if mode == m.mode {
261+ s.modeState[i] = mc
262+ }
263+ }
264+ _, _, workspace := m.picker.Workspace()
265+ for i, kind := range targetKinds {
266+ switch {
267+ case kind == m.picker.Kind():
268+ s.kindState[i] = mc
269+ case kind == core.PickWorkspace && !workspace:
270+ s.kindState[i] = "off"
271+ default:
272+ s.kindState[i] = "idle"
273+ }
274+ }
275+}
276+
277+// place moves a chip laid out from the origin into its group and clips it to
278+// the surface; a chip with no room left gets no area and is not drawn.
279+func place(c *frect, group frect, w, h float64) {
280+ if c.w == 0 {
281+ return
282+ }
283+ c.x += group.x
284+ c.y = group.y
285+ c.w = min(c.w, max(w-c.x, 0))
286+ c.h = min(c.h, max(h-c.y, 0))
287+ if c.w <= 0 || c.h <= 0 {
288+ *c = frect{}
289+ }
290+}
291+
292+// clearLabelOfBadge moves a size label that meets the chips below them,
293+// inside the highlighted rectangle fr when known.
179294 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
295+ for _, r := range [2]frect{s.badge, s.target} {
296+ if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) {
297+ if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h {
298+ s.label.y = y
299+ }
183300 }
184301 }
185302 }
@@ -208,7 +325,12 @@ func footerHeight(text string, width float64) float64 {
208325
209326 // headerWidth is a monospace estimate of the header text plus padding.
210327 func headerWidth(text string) float64 {
211- return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX
328+ return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX
329+}
330+
331+// toastWidth is the same estimate at the toast's larger font.
332+func toastWidth(text string) float64 {
333+ return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX
212334 }
213335
214336 // 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,8 @@ 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. No mode is red: red would read as
73-// tag style: a red badge would read as an active recording.78+// an active recording.
74 func badgeText(mode ports.Mode) string {79 func badgeText(mode ports.Mode) string {
75 if mode == ports.Record {80 if mode == ports.Record {
76 return "REC"81 return "REC"
@@ -78,6 +83,22 @@ func badgeText(mode ports.Mode) string {
78 return "SHOT"83 return "SHOT"
79 }84 }
80 85
86+// modeClass is the stylesheet class that colors the active mode: cyan for
87+// screenshots, amber for recordings.
88+func modeClass(mode ports.Mode) string {
89+ if mode == ports.Record {
90+ return "rec"
91+ }
92+ return "shot"
93+}
94+
95+// Chip labels: the key, then the target. Indexes match targetKinds.
96+var (
97+ modeChips = [2]ports.Mode{ports.Screenshot, ports.Record}
98+ targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace}
99+ targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"}
100+)
101+
81 // frect is a float rectangle in surface-local logical pixels, the unit102 // frect is a float rectangle in surface-local logical pixels, the unit
82 // Node.Rect takes.103 // Node.Rect takes.
83 type frect struct{ x, y, w, h float64 }104 type frect struct{ x, y, w, h float64 }
@@ -90,8 +111,16 @@ type scene struct {
90 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal111 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
91 label frect112 label frect
92 header frect113 header frect
93- badge frect // top-left mode badge114+ badge frect // the mode chips together, top-left
94- footer frect115+ modes [2]frect // SHOT, REC; only the current one without Tab
116+ target frect // the target chips together
117+ kinds [3]frect // region, monitor, workspace
118+ // Chip classes: shot or rec for the active chip, idle, or off for a
119+ // workspace this output does not offer.
120+ modeState [2]string
121+ kindState [3]string
122+ toast frect
123+ footer frect
95 }124 }
96 125
97 // highlight returns the rectangle to outline and whether the rest is dimmed.126 // highlight returns the rectangle to outline and whether the rest is dimmed.
@@ -130,11 +159,12 @@ func (m *model) scene() scene {
130 s.dim[0] = frect{0, 0, w, h}159 s.dim[0] = frect{0, 0, w, h}
131 }160 }
132 if outline {161 if outline {
162+ e := min(max(m.edgeW, edge), fr.w/2, fr.h/2)
133 s.edges = [4]frect{163 s.edges = [4]frect{
134- {fr.x, fr.y, fr.w, edge},164+ {fr.x, fr.y, fr.w, e},
135- {fr.x, fr.y + fr.h - edge, fr.w, edge},165+ {fr.x, fr.y + fr.h - e, fr.w, e},
136- {fr.x, fr.y, edge, fr.h},166+ {fr.x, fr.y, e, fr.h},
137- {fr.x + fr.w - edge, fr.y, edge, fr.h},167+ {fr.x + fr.w - e, fr.y, e, fr.h},
138 }168 }
139 }169 }
140 if m.grid {170 if m.grid {
@@ -145,7 +175,8 @@ func (m *model) scene() scene {
145 }175 }
146 hw, hh := min(headerWidth(m.header), w), min(headerH, h)176 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}177 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
148- s.placeBadge(m.mode, w, h)178+ s.placeChips(m, w, h)
179+ s.chipStates(m)
149 // Keep workspace dimensions clear of the output header; region and180 // Keep workspace dimensions clear of the output header; region and
150 // monitor labels retain their independent placement.181 // monitor labels retain their independent placement.
151 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&182 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -160,26 +191,112 @@ func (m *model) scene() scene {
160 fw := min(footerW, w)191 fw := min(footerW, w)
161 fh := min(footerHeight(m.footerText, fw), h)192 fh := min(footerHeight(m.footerText, fw), h)
162 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}193 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
194+ if m.toast.on {
195+ tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
196+ s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
197+ }
163 return s198 return s
164 }199 }
165 200
166-// placeBadge puts the mode badge top-left; on an output too narrow to share201+// placeChips puts the mode chips top-left and the target chips after them,
167-// the row with the header it moves below it.202+// 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) {203+// narrow to share the row with the header, both move below it.
169- bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)204+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}205+ ch := min(headerH, h)
171- if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {206+ x := 0.0
172- s.badge.y = s.header.y + s.header.h + tagPadY207+ for i, mode := range modeChips {
208+ if !m.toggle && mode != m.mode {
209+ continue
210+ }
211+ cw := headerWidth(badgeText(mode))
212+ if x > 0 {
213+ x += chipGap
214+ }
215+ s.modes[i] = frect{x, 0, cw, ch}
216+ x += cw
217+ }
218+ s.badge = frect{0, 0, x, ch}
219+ x = 0
220+ for i, text := range targetTexts {
221+ cw := headerWidth(text)
222+ if x > 0 {
223+ x += chipGap
224+ }
225+ s.kinds[i] = frect{x, 0, cw, ch}
226+ x += cw
227+ }
228+ s.target = frect{0, 0, x, ch}
229+
230+ top := min(margin, max(h-ch, 0))
231+ left := min(margin, max(w-s.badge.w, 0))
232+ row := frect{left, top, s.badge.w + groupGap + s.target.w, ch}
233+ below := overlaps(row, s.header) || row.x+row.w > w
234+ if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h {
235+ top = s.header.y + s.header.h + tagPadY
236+ row.y = top
237+ below = row.x+row.w > w
238+ }
239+ s.badge.x, s.badge.y = left, top
240+ s.target.x, s.target.y = left+s.badge.w+groupGap, top
241+ if below {
242+ s.target.x, s.target.y = left, top+ch+tagPadY
243+ }
244+ for i := range s.modes {
245+ place(&s.modes[i], s.badge, w, h)
173 }246 }
247+ for i := range s.kinds {
248+ place(&s.kinds[i], s.target, w, h)
249+ }
250+ s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
251+ s.target.w = min(s.target.w, max(w-s.target.x, 0))
174 s.clearLabelOfBadge(frect{}, h)252 s.clearLabelOfBadge(frect{}, h)
175 }253 }
176 254
177-// clearLabelOfBadge moves a size label that meets the badge below it, inside255+// chipStates colors the current mode and target with the mode class.
178-// the highlighted rectangle fr when known.256+func (s *scene) chipStates(m *model) {
257+ mc := modeClass(m.mode)
258+ for i, mode := range modeChips {
259+ s.modeState[i] = "idle"
260+ if mode == m.mode {
261+ s.modeState[i] = mc
262+ }
263+ }
264+ _, _, workspace := m.picker.Workspace()
265+ for i, kind := range targetKinds {
266+ switch {
267+ case kind == m.picker.Kind():
268+ s.kindState[i] = mc
269+ case kind == core.PickWorkspace && !workspace:
270+ s.kindState[i] = "off"
271+ default:
272+ s.kindState[i] = "idle"
273+ }
274+ }
275+}
276+
277+// place moves a chip laid out from the origin into its group and clips it to
278+// the surface; a chip with no room left gets no area and is not drawn.
279+func place(c *frect, group frect, w, h float64) {
280+ if c.w == 0 {
281+ return
282+ }
283+ c.x += group.x
284+ c.y = group.y
285+ c.w = min(c.w, max(w-c.x, 0))
286+ c.h = min(c.h, max(h-c.y, 0))
287+ if c.w <= 0 || c.h <= 0 {
288+ *c = frect{}
289+ }
290+}
291+
292+// clearLabelOfBadge moves a size label that meets the chips below them,
293+// inside the highlighted rectangle fr when known.
179 func (s *scene) clearLabelOfBadge(fr frect, h float64) {294 func (s *scene) clearLabelOfBadge(fr frect, h float64) {
180- if s.label.w > 0 && overlaps(s.label, s.badge) {295+ 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 {296+ if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) {
182- s.label.y = y297+ if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h {
298+ s.label.y = y
299+ }
183 }300 }
184 }301 }
185 }302 }
@@ -208,7 +325,12 @@ func footerHeight(text string, width float64) float64 {
208 325
209 // headerWidth is a monospace estimate of the header text plus padding.326 // headerWidth is a monospace estimate of the header text plus padding.
210 func headerWidth(text string) float64 {327 func headerWidth(text string) float64 {
211- return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX328+ return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX
329+}
330+
331+// toastWidth is the same estimate at the toast's larger font.
332+func toastWidth(text string) float64 {
333+ return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX
212 }334 }
213 335
214 // labelRect places the size label above the region, or just inside it when336 // labelRect places the size label above the region, or just inside it when
M internal/adapters/selection/selection_test.go +12 -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,9 @@ 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+ assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label)
448+ assert.False(t, overlaps(s.label, s.target))
444449 // Dim strips and region tile the output exactly.
445450 area := 200.0 * 200.0
446451 for _, d := range s.dim {
@@ -748,7 +753,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
748753 repeat.Repeat = true
749754 assert.False(t, m.input(repeat), "repeat")
750755 assert.False(t, m.input(key("q")), "unknown key")
751- assert.False(t, m.input(key("w")), "W without a workspace")
756+ assert.True(t, m.input(key("w")), "W without a workspace warns and falls back")
757+ m = newTestModel(t)
752758
753759 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
754760 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -760,9 +766,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
760766 m = newTestModel(t)
761767 assert.True(t, m.input(key("g")), "grid toggles")
762768 assert.True(t, m.input(key("m")), "monitor outline")
763- assert.False(t, m.input(key("m")), "already chosen")
769+ assert.True(t, m.input(key("m")), "an already chosen target is confirmed again")
764770 assert.True(t, m.input(key("r")))
765- assert.False(t, m.input(key("r")))
766771 }
767772
768773 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,9 @@ 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+ assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label)
448+ assert.False(t, overlaps(s.label, s.target))
444 // Dim strips and region tile the output exactly.449 // Dim strips and region tile the output exactly.
445 area := 200.0 * 200.0450 area := 200.0 * 200.0
446 for _, d := range s.dim {451 for _, d := range s.dim {
@@ -748,7 +753,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
748 repeat.Repeat = true753 repeat.Repeat = true
749 assert.False(t, m.input(repeat), "repeat")754 assert.False(t, m.input(repeat), "repeat")
750 assert.False(t, m.input(key("q")), "unknown key")755 assert.False(t, m.input(key("q")), "unknown key")
751- assert.False(t, m.input(key("w")), "W without a workspace")756+ assert.True(t, m.input(key("w")), "W without a workspace warns and falls back")
757+ m = newTestModel(t)
752 758
753 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")759 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")760 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -760,9 +766,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
760 m = newTestModel(t)766 m = newTestModel(t)
761 assert.True(t, m.input(key("g")), "grid toggles")767 assert.True(t, m.input(key("g")), "grid toggles")
762 assert.True(t, m.input(key("m")), "monitor outline")768 assert.True(t, m.input(key("m")), "monitor outline")
763- assert.False(t, m.input(key("m")), "already chosen")769+ assert.True(t, m.input(key("m")), "an already chosen target is confirmed again")
764 assert.True(t, m.input(key("r")))770 assert.True(t, m.input(key("r")))
765- assert.False(t, m.input(key("r")))
766 }771 }
767 772
768 func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {773 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, amber for recordings. Never red, which
13+ would read as a recording in progress. */
14+box.edge.shot { background-color: #38bdf8; }
15+box.edge.rec { background-color: #f5a524; }
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: #f5a524; 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, amber for recordings. Never red, which
13+ would read as a recording in progress. */
14+box.edge.shot { background-color: #38bdf8; }
15+box.edge.rec { background-color: #f5a524; }
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: #f5a524; 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