detached 11965a5

feat(selection): add Tab mode toggle and mode badge

commit
11965a527d76bd7f094ac292b32050e71ac8f8f2
author
brice · · 2026-09-30 15:52 UTC
committer
brice · · 2026-09-30 15:52 UTC
parent
7b74838
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +298 -27
A internal/adapters/selection/mode_test.go +126 -0
@@ -0,0 +1,126 @@
1+package selection
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/nefercap/internal/core"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model {
14+ t.Helper()
15+ m := newTestModel(t)
16+ m.base, m.mode, m.toggle = mode, mode, toggle
17+ m.footerMode = mode
18+ m.footerText = footerText(mode, core.PickRegion, false, toggle)
19+ return m
20+}
21+
22+func TestTabTogglesModeKeepingTarget(t *testing.T) {
23+ m := toggleModel(t, ports.Screenshot, true)
24+ assert.Equal(t, "shot · Tab rec · R region · M monitor · G grid · Esc cancel\ndrag + release capture region · click capture monitor", m.footerText)
25+ assert.Equal(t, "SHOT", badgeText(m.mode))
26+ m.input(key("m"))
27+ require.Equal(t, core.PickMonitor, m.picker.Kind())
28+ assert.True(t, m.input(key("Tab")), "a mode change redraws")
29+ assert.Equal(t, ports.Record, m.mode)
30+ assert.Equal(t, core.PickMonitor, m.picker.Kind(), "target is kept")
31+ assert.Equal(t, "rec · Tab shot · R region · M monitor · G grid · Esc cancel\nEnter record monitor · click record monitor", m.footerText)
32+ assert.Equal(t, "● REC", badgeText(m.mode))
33+ m.input(key("Tab"))
34+ assert.Equal(t, ports.Screenshot, m.mode)
35+ assert.Contains(t, m.footerText, "Enter capture monitor")
36+}
37+
38+func TestTabIgnoredWhenModeFixed(t *testing.T) {
39+ for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
40+ m := toggleModel(t, mode, false)
41+ before := m.footerText
42+ assert.False(t, m.input(key("Tab")))
43+ assert.Equal(t, mode, m.mode)
44+ assert.Equal(t, before, m.footerText)
45+ assert.NotContains(t, m.footerText, "Tab")
46+ }
47+}
48+
49+func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
50+ sess, _ := testSession()
51+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
52+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
53+ a.toggle, b.toggle = true, true
54+ a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1)
55+ sess.wakes = []chan struct{}{a.wake, b.wake}
56+ a.resize(1920, 1080, 1)
57+ b.resize(1280, 720, 1)
58+ a.input(key("Tab"))
59+ require.Len(t, b.wake, 1, "the other overlay is woken")
60+ b.input(motion(1, 1))
61+ assert.Equal(t, ports.Record, b.mode)
62+ assert.Contains(t, b.footerText, "rec · Tab shot")
63+ s := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle()
64+ sel := s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), core.PickResult{OutputID: 7, Kind: core.PickMonitor})
65+ assert.Equal(t, ports.Record, sel.Mode)
66+ assert.Equal(t, DefaultFPS, sel.Video.FPS)
67+ sel = s.selectionFor(ports.Screenshot, core.PickResult{OutputID: 7, Kind: core.PickMonitor})
68+ assert.Equal(t, ports.VideoSettings{}, sel.Video)
69+}
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.Contains(t, styleSheet, "box.rec { background-color: #e5484d;")
75+}
76+
77+func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
78+ for _, w := range []int{1920, 640, 320, 200, 100, 40} {
79+ for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
80+ m := toggleModel(t, mode, true)
81+ m.resize(w, 480, 1)
82+ s := m.scene()
83+ assert.GreaterOrEqual(t, s.badge.x, 0.0)
84+ assert.LessOrEqual(t, s.badge.x+s.badge.w, float64(w))
85+ assert.LessOrEqual(t, s.badge.y+s.badge.h, 480.0)
86+ if w >= 640 {
87+ assert.Equal(t, margin, s.badge.x)
88+ assert.Equal(t, margin, s.badge.y)
89+ }
90+ assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w)
91+ m.input(key("m"))
92+ s = m.scene()
93+ assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w)
94+ assert.False(t, overlaps(s.label, s.badge), "label/badge at %d", w)
95+ }
96+ }
97+}
98+
99+func TestBadgeAvoidsWorkspaceLabelAtLeftEdge(t *testing.T) {
100+ m, _ := newBareModel()
101+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 0, Width: 300, Height: 400}}})
102+ m.resize(640, 480, 1)
103+ m.input(key("w"))
104+ s := m.scene()
105+ assert.False(t, overlaps(s.label, s.badge))
106+ assert.False(t, overlaps(s.label, s.header))
107+}
108+
109+func TestModeToggleAllocations(t *testing.T) {
110+ if raceEnabled {
111+ t.Skip("the race detector adds allocations")
112+ }
113+ m := toggleModel(t, ports.Screenshot, true)
114+ m.input(key("m"))
115+ assert.Zero(t, testing.AllocsPerRun(100, func() {
116+ m.input(motion(10, 10))
117+ _ = m.scene()
118+ _ = badgeText(m.mode)
119+ }), "unchanged mode allocates nothing")
120+ n := testing.AllocsPerRun(100, func() {
121+ m.input(key("Tab"))
122+ _ = m.scene()
123+ })
124+ t.Logf("Tab toggle allocs/run: %v", n)
125+ assert.LessOrEqual(t, n, 1.0, "a toggle allocates only the rebuilt legend text")
126+}
@@ -0,0 +1,126 @@
1+package selection
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/nefercap/internal/core"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model {
14+ t.Helper()
15+ m := newTestModel(t)
16+ m.base, m.mode, m.toggle = mode, mode, toggle
17+ m.footerMode = mode
18+ m.footerText = footerText(mode, core.PickRegion, false, toggle)
19+ return m
20+}
21+
22+func TestTabTogglesModeKeepingTarget(t *testing.T) {
23+ m := toggleModel(t, ports.Screenshot, true)
24+ assert.Equal(t, "shot · Tab rec · R region · M monitor · G grid · Esc cancel\ndrag + release capture region · click capture monitor", m.footerText)
25+ assert.Equal(t, "SHOT", badgeText(m.mode))
26+ m.input(key("m"))
27+ require.Equal(t, core.PickMonitor, m.picker.Kind())
28+ assert.True(t, m.input(key("Tab")), "a mode change redraws")
29+ assert.Equal(t, ports.Record, m.mode)
30+ assert.Equal(t, core.PickMonitor, m.picker.Kind(), "target is kept")
31+ assert.Equal(t, "rec · Tab shot · R region · M monitor · G grid · Esc cancel\nEnter record monitor · click record monitor", m.footerText)
32+ assert.Equal(t, "● REC", badgeText(m.mode))
33+ m.input(key("Tab"))
34+ assert.Equal(t, ports.Screenshot, m.mode)
35+ assert.Contains(t, m.footerText, "Enter capture monitor")
36+}
37+
38+func TestTabIgnoredWhenModeFixed(t *testing.T) {
39+ for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
40+ m := toggleModel(t, mode, false)
41+ before := m.footerText
42+ assert.False(t, m.input(key("Tab")))
43+ assert.Equal(t, mode, m.mode)
44+ assert.Equal(t, before, m.footerText)
45+ assert.NotContains(t, m.footerText, "Tab")
46+ }
47+}
48+
49+func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
50+ sess, _ := testSession()
51+ a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
52+ b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
53+ a.toggle, b.toggle = true, true
54+ a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1)
55+ sess.wakes = []chan struct{}{a.wake, b.wake}
56+ a.resize(1920, 1080, 1)
57+ b.resize(1280, 720, 1)
58+ a.input(key("Tab"))
59+ require.Len(t, b.wake, 1, "the other overlay is woken")
60+ b.input(motion(1, 1))
61+ assert.Equal(t, ports.Record, b.mode)
62+ assert.Contains(t, b.footerText, "rec · Tab shot")
63+ s := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle()
64+ sel := s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), core.PickResult{OutputID: 7, Kind: core.PickMonitor})
65+ assert.Equal(t, ports.Record, sel.Mode)
66+ assert.Equal(t, DefaultFPS, sel.Video.FPS)
67+ sel = s.selectionFor(ports.Screenshot, core.PickResult{OutputID: 7, Kind: core.PickMonitor})
68+ assert.Equal(t, ports.VideoSettings{}, sel.Video)
69+}
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.Contains(t, styleSheet, "box.rec { background-color: #e5484d;")
75+}
76+
77+func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
78+ for _, w := range []int{1920, 640, 320, 200, 100, 40} {
79+ for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
80+ m := toggleModel(t, mode, true)
81+ m.resize(w, 480, 1)
82+ s := m.scene()
83+ assert.GreaterOrEqual(t, s.badge.x, 0.0)
84+ assert.LessOrEqual(t, s.badge.x+s.badge.w, float64(w))
85+ assert.LessOrEqual(t, s.badge.y+s.badge.h, 480.0)
86+ if w >= 640 {
87+ assert.Equal(t, margin, s.badge.x)
88+ assert.Equal(t, margin, s.badge.y)
89+ }
90+ assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w)
91+ m.input(key("m"))
92+ s = m.scene()
93+ assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w)
94+ assert.False(t, overlaps(s.label, s.badge), "label/badge at %d", w)
95+ }
96+ }
97+}
98+
99+func TestBadgeAvoidsWorkspaceLabelAtLeftEdge(t *testing.T) {
100+ m, _ := newBareModel()
101+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 0, Width: 300, Height: 400}}})
102+ m.resize(640, 480, 1)
103+ m.input(key("w"))
104+ s := m.scene()
105+ assert.False(t, overlaps(s.label, s.badge))
106+ assert.False(t, overlaps(s.label, s.header))
107+}
108+
109+func TestModeToggleAllocations(t *testing.T) {
110+ if raceEnabled {
111+ t.Skip("the race detector adds allocations")
112+ }
113+ m := toggleModel(t, ports.Screenshot, true)
114+ m.input(key("m"))
115+ assert.Zero(t, testing.AllocsPerRun(100, func() {
116+ m.input(motion(10, 10))
117+ _ = m.scene()
118+ _ = badgeText(m.mode)
119+ }), "unchanged mode allocates nothing")
120+ n := testing.AllocsPerRun(100, func() {
121+ m.input(key("Tab"))
122+ _ = m.scene()
123+ })
124+ t.Logf("Tab toggle allocs/run: %v", n)
125+ assert.LessOrEqual(t, n, 1.0, "a toggle allocates only the rebuilt legend text")
126+}
M internal/adapters/selection/model.go +42 -7
@@ -27,7 +27,11 @@ const (
2727 // here is locked. The decision leaves through the shared session, never through
2828 // this struct.
2929 type model struct {
30- mode ports.Mode
30+ base ports.Mode // mode the selector started in
31+ mode ports.Mode // current mode: base flipped by the session's Tab toggles
32+ toggle bool // Tab switches shot <-> rec (all-in-one only)
33+ seen uint32 // session toggles already applied to mode
34+ wake chan struct{}
3135 outputs []ports.Output
3236 index int // the output this overlay covers; every output has its own model
3337
@@ -48,12 +52,36 @@ type model struct {
4852 header string // static per surface
4953 footerText string
5054 footerKind core.PickKind
55+ footerMode ports.Mode
5156 footerWorkspace bool
5257 label label
5358 }
5459
5560 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
56- return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)}
61+ return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false)}
62+}
63+
64+// otherMode is the mode a Tab switches to.
65+func otherMode(mode ports.Mode) ports.Mode {
66+ if mode == ports.Record {
67+ return ports.Screenshot
68+ }
69+ return ports.Record
70+}
71+
72+// modeAfter is base after n toggles.
73+func modeAfter(base ports.Mode, n uint32) ports.Mode {
74+ if n%2 == 1 {
75+ return otherMode(base)
76+ }
77+ return base
78+}
79+
80+// syncMode applies toggles made on any overlay. It never allocates.
81+func (m *model) syncMode() {
82+ if n := m.sess.toggles.Load(); n != m.seen {
83+ m.seen, m.mode = n, modeAfter(m.base, n)
84+ }
5785 }
5886
5987 // resize is the OnResize callback. A logical size change (re)starts the
@@ -158,6 +186,7 @@ func (m *model) fail(err error) {
158186 // and never allocates.
159187 type visible struct {
160188 rect ports.Region
189+ rec bool
161190 dragging bool
162191 kind core.PickKind
163192 grid bool
@@ -166,7 +195,7 @@ type visible struct {
166195
167196 func (m *model) snapshot() visible {
168197 r, _ := m.picker.Rect()
169- return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}
198+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}
170199 }
171200
172201 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -178,6 +207,7 @@ func (m *model) input(ev nefergui.InputEvent) bool {
178207 if m.end != endNone || !m.sized {
179208 return false
180209 }
210+ m.syncMode()
181211 before, endBefore := m.snapshot(), m.end
182212 switch ev.Kind {
183213 case nefergui.InputPointerMotion:
@@ -215,6 +245,11 @@ func (m *model) key(name string) {
215245 m.picker.SelectRegion()
216246 case "g", "G":
217247 m.grid = !m.grid
248+ case "Tab":
249+ if m.toggle {
250+ m.sess.toggle()
251+ m.syncMode()
252+ }
218253 case "Return", "KP_Enter":
219254 m.picker.Confirm()
220255 default:
@@ -235,13 +270,13 @@ func outputKey(name string, n int) (int, bool) {
235270 return i, i < n
236271 }
237272
238-// refreshFooter rebuilds the footer only when the target kind or workspace changes.
273+// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.
239274 func (m *model) refreshFooter() {
240275 kind := m.picker.Kind()
241276 _, _, workspace := m.picker.Workspace()
242- if kind != m.footerKind || workspace != m.footerWorkspace {
243- m.footerKind, m.footerWorkspace = kind, workspace
244- m.footerText = footerText(m.mode, kind, workspace)
277+ if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {
278+ m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode
279+ m.footerText = footerText(m.mode, kind, workspace, m.toggle)
245280 }
246281 }
247282
@@ -27,7 +27,11 @@ const (
27 // here is locked. The decision leaves through the shared session, never through27 // here is locked. The decision leaves through the shared session, never through
28 // this struct.28 // this struct.
29 type model struct {29 type model struct {
30- mode ports.Mode30+ base ports.Mode // mode the selector started in
31+ mode ports.Mode // current mode: base flipped by the session's Tab toggles
32+ toggle bool // Tab switches shot <-> rec (all-in-one only)
33+ seen uint32 // session toggles already applied to mode
34+ wake chan struct{}
31 outputs []ports.Output35 outputs []ports.Output
32 index int // the output this overlay covers; every output has its own model36 index int // the output this overlay covers; every output has its own model
33 37
@@ -48,12 +52,36 @@ type model struct {
48 header string // static per surface52 header string // static per surface
49 footerText string53 footerText string
50 footerKind core.PickKind54 footerKind core.PickKind
55+ footerMode ports.Mode
51 footerWorkspace bool56 footerWorkspace bool
52 label label57 label label
53 }58 }
54 59
55 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {60 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
56- return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)}61+ return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false)}
62+}
63+
64+// otherMode is the mode a Tab switches to.
65+func otherMode(mode ports.Mode) ports.Mode {
66+ if mode == ports.Record {
67+ return ports.Screenshot
68+ }
69+ return ports.Record
70+}
71+
72+// modeAfter is base after n toggles.
73+func modeAfter(base ports.Mode, n uint32) ports.Mode {
74+ if n%2 == 1 {
75+ return otherMode(base)
76+ }
77+ return base
78+}
79+
80+// syncMode applies toggles made on any overlay. It never allocates.
81+func (m *model) syncMode() {
82+ if n := m.sess.toggles.Load(); n != m.seen {
83+ m.seen, m.mode = n, modeAfter(m.base, n)
84+ }
57 }85 }
58 86
59 // resize is the OnResize callback. A logical size change (re)starts the87 // resize is the OnResize callback. A logical size change (re)starts the
@@ -158,6 +186,7 @@ func (m *model) fail(err error) {
158 // and never allocates.186 // and never allocates.
159 type visible struct {187 type visible struct {
160 rect ports.Region188 rect ports.Region
189+ rec bool
161 dragging bool190 dragging bool
162 kind core.PickKind191 kind core.PickKind
163 grid bool192 grid bool
@@ -166,7 +195,7 @@ type visible struct {
166 195
167 func (m *model) snapshot() visible {196 func (m *model) snapshot() visible {
168 r, _ := m.picker.Rect()197 r, _ := m.picker.Rect()
169- return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}198+ return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}
170 }199 }
171 200
172 // input is the OnInput callback. It reports whether the drawn state changed,201 // input is the OnInput callback. It reports whether the drawn state changed,
@@ -178,6 +207,7 @@ func (m *model) input(ev nefergui.InputEvent) bool {
178 if m.end != endNone || !m.sized {207 if m.end != endNone || !m.sized {
179 return false208 return false
180 }209 }
210+ m.syncMode()
181 before, endBefore := m.snapshot(), m.end211 before, endBefore := m.snapshot(), m.end
182 switch ev.Kind {212 switch ev.Kind {
183 case nefergui.InputPointerMotion:213 case nefergui.InputPointerMotion:
@@ -215,6 +245,11 @@ func (m *model) key(name string) {
215 m.picker.SelectRegion()245 m.picker.SelectRegion()
216 case "g", "G":246 case "g", "G":
217 m.grid = !m.grid247 m.grid = !m.grid
248+ case "Tab":
249+ if m.toggle {
250+ m.sess.toggle()
251+ m.syncMode()
252+ }
218 case "Return", "KP_Enter":253 case "Return", "KP_Enter":
219 m.picker.Confirm()254 m.picker.Confirm()
220 default:255 default:
@@ -235,13 +270,13 @@ func outputKey(name string, n int) (int, bool) {
235 return i, i < n270 return i, i < n
236 }271 }
237 272
238-// refreshFooter rebuilds the footer only when the target kind or workspace changes.273+// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.
239 func (m *model) refreshFooter() {274 func (m *model) refreshFooter() {
240 kind := m.picker.Kind()275 kind := m.picker.Kind()
241 _, _, workspace := m.picker.Workspace()276 _, _, workspace := m.picker.Workspace()
242- if kind != m.footerKind || workspace != m.footerWorkspace {277+ if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {
243- m.footerKind, m.footerWorkspace = kind, workspace278+ m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode
244- m.footerText = footerText(m.mode, kind, workspace)279+ m.footerText = footerText(m.mode, kind, workspace, m.toggle)
245 }280 }
246 }281 }
247 282
M internal/adapters/selection/scene.go +68 -10
@@ -1,6 +1,8 @@
11 package selection
22
33 import (
4+ "strings"
5+
46 "github.com/bnema/nefercap/internal/core"
57 "github.com/bnema/nefercap/internal/ports"
68 )
@@ -20,23 +22,51 @@ const (
2022
2123 // footerText is rebuilt only when the capture mode, picker kind or workspace
2224 // availability changes; pointer motion never formats hints.
23-func footerText(mode ports.Mode, kind core.PickKind, workspace bool) string {
24- text, action := "shot · R region · M monitor", "capture"
25+func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) string {
26+ action, name, other := "capture", "shot", "rec"
2527 if mode == ports.Record {
26- text, action = "rec · R region · M monitor", "record"
28+ action, name, other = "record", "rec", "shot"
29+ }
30+ var b strings.Builder
31+ b.Grow(192) // one allocation for the whole legend
32+ b.WriteString(name)
33+ if toggle {
34+ b.WriteString(" · Tab ")
35+ b.WriteString(other)
2736 }
37+ b.WriteString(" · R region · M monitor")
2838 if workspace {
29- text += " · W workspace"
39+ b.WriteString(" · W workspace")
3040 }
31- text += " · G grid · Esc cancel\n"
41+ b.WriteString(" · G grid · Esc cancel\n")
3242 switch kind {
33- case core.PickMonitor:
34- return text + "Enter " + action + " monitor · click " + action + " monitor"
35- case core.PickWorkspace:
36- return text + "Enter " + action + " workspace · click " + action + " workspace"
43+ case core.PickMonitor, core.PickWorkspace:
44+ target := " monitor"
45+ if kind == core.PickWorkspace {
46+ target = " workspace"
47+ }
48+ b.WriteString("Enter ")
49+ b.WriteString(action)
50+ b.WriteString(target)
51+ b.WriteString(" · click ")
52+ b.WriteString(action)
53+ b.WriteString(target)
3754 default:
38- return text + "drag + release " + action + " region · click " + action + " monitor"
55+ b.WriteString("drag + release ")
56+ b.WriteString(action)
57+ b.WriteString(" region · click ")
58+ b.WriteString(action)
59+ b.WriteString(" monitor")
3960 }
61+ return b.String()
62+}
63+
64+// badgeText is the always-visible mode badge; recBadge is styled red.
65+func badgeText(mode ports.Mode) string {
66+ if mode == ports.Record {
67+ return "● REC"
68+ }
69+ return "SHOT"
4070 }
4171
4272 // frect is a float rectangle in surface-local logical pixels, the unit
@@ -51,6 +81,7 @@ type scene struct {
5181 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
5282 label frect
5383 header frect
84+ badge frect // top-left mode badge
5485 footer frect
5586 }
5687
@@ -105,6 +136,7 @@ func (m *model) scene() scene {
105136 }
106137 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
107138 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
139+ s.placeBadge(m.mode, w, h)
108140 // Keep workspace dimensions clear of the output header; region and
109141 // monitor labels retain their independent placement.
110142 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -115,12 +147,38 @@ func (m *model) scene() scene {
115147 s.label.y = y
116148 }
117149 }
150+ s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it
118151 fw := min(footerW, w)
119152 fh := min(footerHeight(m.footerText, fw), h)
120153 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
121154 return s
122155 }
123156
157+// placeBadge puts the mode badge top-left; on an output too narrow to share
158+// the row with the header it moves below it.
159+func (s *scene) placeBadge(mode ports.Mode, w, h float64) {
160+ bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)
161+ s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh}
162+ if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {
163+ s.badge.y = s.header.y + s.header.h + tagPadY
164+ }
165+ s.clearLabelOfBadge(frect{}, h)
166+}
167+
168+// clearLabelOfBadge moves a size label that meets the badge below it, inside
169+// the highlighted rectangle fr when known.
170+func (s *scene) clearLabelOfBadge(fr frect, h float64) {
171+ if s.label.w > 0 && overlaps(s.label, s.badge) {
172+ if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h {
173+ s.label.y = y
174+ }
175+ }
176+}
177+
178+func overlaps(a, b frect) bool {
179+ return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y
180+}
181+
124182 // footerHeight counts every explicit row separately, reserving conservative
125183 // monospace cells and metric lines for wrapping. Range counts runes (including
126184 // the middle-dot separators) without allocating per scene.
@@ -1,6 +1,8 @@
1 package selection1 package selection
2 2
3 import (3 import (
4+ "strings"
5+
4 "github.com/bnema/nefercap/internal/core"6 "github.com/bnema/nefercap/internal/core"
5 "github.com/bnema/nefercap/internal/ports"7 "github.com/bnema/nefercap/internal/ports"
6 )8 )
@@ -20,23 +22,51 @@ const (
20 22
21 // footerText is rebuilt only when the capture mode, picker kind or workspace23 // footerText is rebuilt only when the capture mode, picker kind or workspace
22 // availability changes; pointer motion never formats hints.24 // availability changes; pointer motion never formats hints.
23-func footerText(mode ports.Mode, kind core.PickKind, workspace bool) string {25+func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) string {
24- text, action := "shot · R region · M monitor", "capture"26+ action, name, other := "capture", "shot", "rec"
25 if mode == ports.Record {27 if mode == ports.Record {
26- text, action = "rec · R region · M monitor", "record"28+ action, name, other = "record", "rec", "shot"
29+ }
30+ var b strings.Builder
31+ b.Grow(192) // one allocation for the whole legend
32+ b.WriteString(name)
33+ if toggle {
34+ b.WriteString(" · Tab ")
35+ b.WriteString(other)
27 }36 }
37+ b.WriteString(" · R region · M monitor")
28 if workspace {38 if workspace {
29- text += " · W workspace"39+ b.WriteString(" · W workspace")
30 }40 }
31- text += " · G grid · Esc cancel\n"41+ b.WriteString(" · G grid · Esc cancel\n")
32 switch kind {42 switch kind {
33- case core.PickMonitor:43+ case core.PickMonitor, core.PickWorkspace:
34- return text + "Enter " + action + " monitor · click " + action + " monitor"44+ target := " monitor"
35- case core.PickWorkspace:45+ if kind == core.PickWorkspace {
36- return text + "Enter " + action + " workspace · click " + action + " workspace"46+ target = " workspace"
47+ }
48+ b.WriteString("Enter ")
49+ b.WriteString(action)
50+ b.WriteString(target)
51+ b.WriteString(" · click ")
52+ b.WriteString(action)
53+ b.WriteString(target)
37 default:54 default:
38- return text + "drag + release " + action + " region · click " + action + " monitor"55+ b.WriteString("drag + release ")
56+ b.WriteString(action)
57+ b.WriteString(" region · click ")
58+ b.WriteString(action)
59+ b.WriteString(" monitor")
39 }60 }
61+ return b.String()
62+}
63+
64+// badgeText is the always-visible mode badge; recBadge is styled red.
65+func badgeText(mode ports.Mode) string {
66+ if mode == ports.Record {
67+ return "● REC"
68+ }
69+ return "SHOT"
40 }70 }
41 71
42 // frect is a float rectangle in surface-local logical pixels, the unit72 // frect is a float rectangle in surface-local logical pixels, the unit
@@ -51,6 +81,7 @@ type scene struct {
51 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal81 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
52 label frect82 label frect
53 header frect83 header frect
84+ badge frect // top-left mode badge
54 footer frect85 footer frect
55 }86 }
56 87
@@ -105,6 +136,7 @@ func (m *model) scene() scene {
105 }136 }
106 hw, hh := min(headerWidth(m.header), w), min(headerH, h)137 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
107 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}138 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
139+ s.placeBadge(m.mode, w, h)
108 // Keep workspace dimensions clear of the output header; region and140 // Keep workspace dimensions clear of the output header; region and
109 // monitor labels retain their independent placement.141 // monitor labels retain their independent placement.
110 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&142 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
@@ -115,12 +147,38 @@ func (m *model) scene() scene {
115 s.label.y = y147 s.label.y = y
116 }148 }
117 }149 }
150+ s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it
118 fw := min(footerW, w)151 fw := min(footerW, w)
119 fh := min(footerHeight(m.footerText, fw), h)152 fh := min(footerHeight(m.footerText, fw), h)
120 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}153 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
121 return s154 return s
122 }155 }
123 156
157+// placeBadge puts the mode badge top-left; on an output too narrow to share
158+// the row with the header it moves below it.
159+func (s *scene) placeBadge(mode ports.Mode, w, h float64) {
160+ bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)
161+ s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh}
162+ if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {
163+ s.badge.y = s.header.y + s.header.h + tagPadY
164+ }
165+ s.clearLabelOfBadge(frect{}, h)
166+}
167+
168+// clearLabelOfBadge moves a size label that meets the badge below it, inside
169+// the highlighted rectangle fr when known.
170+func (s *scene) clearLabelOfBadge(fr frect, h float64) {
171+ if s.label.w > 0 && overlaps(s.label, s.badge) {
172+ if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h {
173+ s.label.y = y
174+ }
175+ }
176+}
177+
178+func overlaps(a, b frect) bool {
179+ return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y
180+}
181+
124 // footerHeight counts every explicit row separately, reserving conservative182 // footerHeight counts every explicit row separately, reserving conservative
125 // monospace cells and metric lines for wrapping. Range counts runes (including183 // monospace cells and metric lines for wrapping. Range counts runes (including
126 // the middle-dot separators) without allocating per scene.184 // the middle-dot separators) without allocating per scene.
M internal/adapters/selection/selection_test.go +2 -2
@@ -632,8 +632,8 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) {
632632 }
633633
634634 func TestFooterAndHeaderText(t *testing.T) {
635- assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false), "shot · R region")
636- assert.Contains(t, footerText(ports.Record, core.PickRegion, false), "rec · R region")
635+ assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · R region")
636+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false), "rec · R region")
637637 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
638638 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
639639 i, ok := outputKey("2", 2)
@@ -632,8 +632,8 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) {
632 }632 }
633 633
634 func TestFooterAndHeaderText(t *testing.T) {634 func TestFooterAndHeaderText(t *testing.T) {
635- assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false), "shot · R region")635+ assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · R region")
636- assert.Contains(t, footerText(ports.Record, core.PickRegion, false), "rec · R region")636+ assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false), "rec · R region")
637 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))637 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
638 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))638 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
639 i, ok := outputKey("2", 2)639 i, ok := outputKey("2", 2)
M internal/adapters/selection/selector.go +29 -8
@@ -49,6 +49,7 @@ type Selector struct {
4949 video ports.VideoSettings
5050 workspaces []ports.Workspace
5151 grid bool
52+ toggle bool
5253 }
5354
5455 var _ ports.Selector = (*Selector)(nil)
@@ -62,15 +63,19 @@ var _ ports.Selector = (*Selector)(nil)
6263 // Selection.Path. The first output passed to Select opens first, so the caller
6364 // orders outputs to put the preferred one first.
6465 func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {
65- if mode == ports.Record && video.FPS == 0 {
66+ if video.FPS == 0 {
6667 video.FPS = DefaultFPS
6768 }
68- if mode != ports.Record {
69- video = ports.VideoSettings{}
70- }
7169 return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}
7270 }
7371
72+// AllowToggle lets Tab switch between screenshot and record selection. The
73+// starting mode is New's; the target selection is kept across a switch.
74+func (s *Selector) AllowToggle() *Selector {
75+ s.toggle = true
76+ return s
77+}
78+
7479 // Select blocks until the user accepts a selection (true), aborts with Escape
7580 // (false, nil) or ctx ends or the window system fails (error). One overlay
7681 // opens on every output at once, so the whole desktop is selectable without
@@ -108,6 +113,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
108113 for i := range outputs {
109114 m := newModel(s.mode, outputs, i, sess, s.grid)
110115 m.setWorkspaces(s.workspaces)
116+ if s.toggle {
117+ m.toggle, m.wake = true, make(chan struct{}, 1)
118+ sess.wakes = append(sess.wakes, m.wake)
119+ }
111120 wg.Add(1)
112121 go func() {
113122 defer wg.Done()
@@ -119,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
119128 if err != nil || !ok {
120129 return ports.Selection{}, false, err
121130 }
122- return s.selection(result), true, nil
131+ return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil
123132 }
124133
125134 // run shows one overlay on its own output until the context ends.
@@ -128,7 +137,7 @@ func run(ctx context.Context, sheet string, m *model) error {
128137 if m.index == 0 {
129138 kb = nefergui.KeyboardExclusive
130139 }
131- return nefergui.Run(ctx, m, view,
140+ opts := []nefergui.WindowOption{
132141 nefergui.Title("NeferCap selector"),
133142 nefergui.Size(1, 1),
134143 nefergui.Styles(sheet),
@@ -136,7 +145,11 @@ func run(ctx context.Context, sheet string, m *model) error {
136145 nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),
137146 nefergui.OnResize(m.resize),
138147 nefergui.OnInput(m.input),
139- )
148+ }
149+ if m.wake != nil {
150+ opts = append(opts, nefergui.Wake(m.wake))
151+ }
152+ return nefergui.Run(ctx, m, view, opts...)
140153 }
141154
142155 // layerConfig is one overlay: every edge, above windows, the given keyboard
@@ -157,5 +170,13 @@ func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerCo
157170 // comes live from the compositor, identified by WorkspaceID), a dragged region
158171 // keeps its explicit output-local geometry.
159172 func (s *Selector) selection(r core.PickResult) ports.Selection {
160- return ports.Selection{Mode: s.mode, Target: r.Target(), Video: s.video}
173+ return s.selectionFor(s.mode, r)
174+}
175+
176+func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection {
177+ sel := ports.Selection{Mode: mode, Target: r.Target()}
178+ if mode == ports.Record {
179+ sel.Video = s.video
180+ }
181+ return sel
161182 }
@@ -49,6 +49,7 @@ type Selector struct {
49 video ports.VideoSettings49 video ports.VideoSettings
50 workspaces []ports.Workspace50 workspaces []ports.Workspace
51 grid bool51 grid bool
52+ toggle bool
52 }53 }
53 54
54 var _ ports.Selector = (*Selector)(nil)55 var _ ports.Selector = (*Selector)(nil)
@@ -62,15 +63,19 @@ var _ ports.Selector = (*Selector)(nil)
62 // Selection.Path. The first output passed to Select opens first, so the caller63 // Selection.Path. The first output passed to Select opens first, so the caller
63 // orders outputs to put the preferred one first.64 // orders outputs to put the preferred one first.
64 func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {65 func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {
65- if mode == ports.Record && video.FPS == 0 {66+ if video.FPS == 0 {
66 video.FPS = DefaultFPS67 video.FPS = DefaultFPS
67 }68 }
68- if mode != ports.Record {
69- video = ports.VideoSettings{}
70- }
71 return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}69 return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}
72 }70 }
73 71
72+// AllowToggle lets Tab switch between screenshot and record selection. The
73+// starting mode is New's; the target selection is kept across a switch.
74+func (s *Selector) AllowToggle() *Selector {
75+ s.toggle = true
76+ return s
77+}
78+
74 // Select blocks until the user accepts a selection (true), aborts with Escape79 // Select blocks until the user accepts a selection (true), aborts with Escape
75 // (false, nil) or ctx ends or the window system fails (error). One overlay80 // (false, nil) or ctx ends or the window system fails (error). One overlay
76 // opens on every output at once, so the whole desktop is selectable without81 // opens on every output at once, so the whole desktop is selectable without
@@ -108,6 +113,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
108 for i := range outputs {113 for i := range outputs {
109 m := newModel(s.mode, outputs, i, sess, s.grid)114 m := newModel(s.mode, outputs, i, sess, s.grid)
110 m.setWorkspaces(s.workspaces)115 m.setWorkspaces(s.workspaces)
116+ if s.toggle {
117+ m.toggle, m.wake = true, make(chan struct{}, 1)
118+ sess.wakes = append(sess.wakes, m.wake)
119+ }
111 wg.Add(1)120 wg.Add(1)
112 go func() {121 go func() {
113 defer wg.Done()122 defer wg.Done()
@@ -119,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
119 if err != nil || !ok {128 if err != nil || !ok {
120 return ports.Selection{}, false, err129 return ports.Selection{}, false, err
121 }130 }
122- return s.selection(result), true, nil131+ return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil
123 }132 }
124 133
125 // run shows one overlay on its own output until the context ends.134 // run shows one overlay on its own output until the context ends.
@@ -128,7 +137,7 @@ func run(ctx context.Context, sheet string, m *model) error {
128 if m.index == 0 {137 if m.index == 0 {
129 kb = nefergui.KeyboardExclusive138 kb = nefergui.KeyboardExclusive
130 }139 }
131- return nefergui.Run(ctx, m, view,140+ opts := []nefergui.WindowOption{
132 nefergui.Title("NeferCap selector"),141 nefergui.Title("NeferCap selector"),
133 nefergui.Size(1, 1),142 nefergui.Size(1, 1),
134 nefergui.Styles(sheet),143 nefergui.Styles(sheet),
@@ -136,7 +145,11 @@ func run(ctx context.Context, sheet string, m *model) error {
136 nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),145 nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),
137 nefergui.OnResize(m.resize),146 nefergui.OnResize(m.resize),
138 nefergui.OnInput(m.input),147 nefergui.OnInput(m.input),
139- )148+ }
149+ if m.wake != nil {
150+ opts = append(opts, nefergui.Wake(m.wake))
151+ }
152+ return nefergui.Run(ctx, m, view, opts...)
140 }153 }
141 154
142 // layerConfig is one overlay: every edge, above windows, the given keyboard155 // layerConfig is one overlay: every edge, above windows, the given keyboard
@@ -157,5 +170,13 @@ func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerCo
157 // comes live from the compositor, identified by WorkspaceID), a dragged region170 // comes live from the compositor, identified by WorkspaceID), a dragged region
158 // keeps its explicit output-local geometry.171 // keeps its explicit output-local geometry.
159 func (s *Selector) selection(r core.PickResult) ports.Selection {172 func (s *Selector) selection(r core.PickResult) ports.Selection {
160- return ports.Selection{Mode: s.mode, Target: r.Target(), Video: s.video}173+ return s.selectionFor(s.mode, r)
174+}
175+
176+func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection {
177+ sel := ports.Selection{Mode: mode, Target: r.Target()}
178+ if mode == ports.Record {
179+ sel.Video = s.video
180+ }
181+ return sel
161 }182 }
M internal/adapters/selection/session.go +17 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "sync"
8+ "sync/atomic"
89
910 "github.com/bnema/nefercap/internal/adapters/uierrors"
1011 "github.com/bnema/nefercap/internal/core"
@@ -19,6 +20,11 @@ var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
1920 type session struct {
2021 cancel context.CancelFunc
2122
23+ // toggles counts Tab presses on any overlay; wakes redraw every overlay
24+ // after one. wakes is set before the overlays start and never changes.
25+ toggles atomic.Uint32
26+ wakes []chan struct{}
27+
2228 mu sync.Mutex
2329 decided bool
2430 accepted bool
@@ -28,6 +34,17 @@ type session struct {
2834
2935 func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
3036
37+// toggle flips the capture mode for every overlay and asks each to redraw.
38+func (s *session) toggle() {
39+ s.toggles.Add(1)
40+ for _, w := range s.wakes {
41+ select {
42+ case w <- struct{}{}:
43+ default:
44+ }
45+ }
46+}
47+
3148 // accept records the first decision and ends every overlay.
3249 func (s *session) accept(r core.PickResult) {
3350 s.mu.Lock()
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "sync"7 "sync"
8+ "sync/atomic"
8 9
9 "github.com/bnema/nefercap/internal/adapters/uierrors"10 "github.com/bnema/nefercap/internal/adapters/uierrors"
10 "github.com/bnema/nefercap/internal/core"11 "github.com/bnema/nefercap/internal/core"
@@ -19,6 +20,11 @@ var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
19 type session struct {20 type session struct {
20 cancel context.CancelFunc21 cancel context.CancelFunc
21 22
23+ // toggles counts Tab presses on any overlay; wakes redraw every overlay
24+ // after one. wakes is set before the overlays start and never changes.
25+ toggles atomic.Uint32
26+ wakes []chan struct{}
27+
22 mu sync.Mutex28 mu sync.Mutex
23 decided bool29 decided bool
24 accepted bool30 accepted bool
@@ -28,6 +34,17 @@ type session struct {
28 34
29 func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }35 func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
30 36
37+// toggle flips the capture mode for every overlay and asks each to redraw.
38+func (s *session) toggle() {
39+ s.toggles.Add(1)
40+ for _, w := range s.wakes {
41+ select {
42+ case w <- struct{}{}:
43+ default:
44+ }
45+ }
46+}
47+
31 // accept records the first decision and ends every overlay.48 // accept records the first decision and ends every overlay.
32 func (s *session) accept(r core.PickResult) {49 func (s *session) accept(r core.PickResult) {
33 s.mu.Lock()50 s.mu.Lock()
M internal/adapters/selection/style.css +2 -0
@@ -13,3 +13,5 @@ box.tag {
1313 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
1414 font-family: monospace; font-size: 1rem; text-align: center;
1515 }
16+/* Recording mode badge: the HUD's red. */
17+box.rec { background-color: #e5484d; color: #ffffff; }
@@ -13,3 +13,5 @@ box.tag {
13 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;13 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
14 font-family: monospace; font-size: 1rem; text-align: center;14 font-family: monospace; font-size: 1rem; text-align: center;
15 }15 }
16+/* Recording mode badge: the HUD's red. */
17+box.rec { background-color: #e5484d; color: #ffffff; }
M internal/adapters/selection/view.go +12 -0
@@ -2,6 +2,8 @@ package selection
22
33 import (
44 "github.com/bnema/nefergui"
5+
6+ "github.com/bnema/nefercap/internal/ports"
57 )
68
79 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings
@@ -9,6 +11,8 @@ import (
911 // area are not emitted at all, so an idle surface is one dim box plus the
1012 // header and footer, and no empty chip is painted.
1113 func view(f *nefergui.Frame, m *model) {
14+ m.syncMode() // a Tab on another overlay redraws this one
15+ m.refreshFooter()
1216 root := f.Root(nefergui.Class("root"))
1317 st := root.Stack(nefergui.Class("scene"))
1418 s := m.scene()
@@ -24,6 +28,14 @@ func view(f *nefergui.Frame, m *model) {
2428 if n := tag(st, "label", s.label); n.ok {
2529 n.node.Text(m.label.text)
2630 }
31+ if s.badge.w > 0 && s.badge.h > 0 {
32+ class := "tag"
33+ if m.mode == ports.Record {
34+ class = "rec"
35+ }
36+ st.Box(nefergui.Key("badge"), nefergui.Class("tag"), nefergui.Class(class)).
37+ Rect(s.badge.x, s.badge.y, s.badge.w, s.badge.h).Text(badgeText(m.mode))
38+ }
2739 if n := tag(st, "header", s.header); n.ok {
2840 n.node.Text(m.header)
2941 }
@@ -2,6 +2,8 @@ package selection
2 2
3 import (3 import (
4 "github.com/bnema/nefergui"4 "github.com/bnema/nefergui"
5+
6+ "github.com/bnema/nefercap/internal/ports"
5 )7 )
6 8
7 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings9 // view draws the scene. Geometry goes through Node.Rect, so no CSS strings
@@ -9,6 +11,8 @@ import (
9 // area are not emitted at all, so an idle surface is one dim box plus the11 // area are not emitted at all, so an idle surface is one dim box plus the
10 // header and footer, and no empty chip is painted.12 // header and footer, and no empty chip is painted.
11 func view(f *nefergui.Frame, m *model) {13 func view(f *nefergui.Frame, m *model) {
14+ m.syncMode() // a Tab on another overlay redraws this one
15+ m.refreshFooter()
12 root := f.Root(nefergui.Class("root"))16 root := f.Root(nefergui.Class("root"))
13 st := root.Stack(nefergui.Class("scene"))17 st := root.Stack(nefergui.Class("scene"))
14 s := m.scene()18 s := m.scene()
@@ -24,6 +28,14 @@ func view(f *nefergui.Frame, m *model) {
24 if n := tag(st, "label", s.label); n.ok {28 if n := tag(st, "label", s.label); n.ok {
25 n.node.Text(m.label.text)29 n.node.Text(m.label.text)
26 }30 }
31+ if s.badge.w > 0 && s.badge.h > 0 {
32+ class := "tag"
33+ if m.mode == ports.Record {
34+ class = "rec"
35+ }
36+ st.Box(nefergui.Key("badge"), nefergui.Class("tag"), nefergui.Class(class)).
37+ Rect(s.badge.x, s.badge.y, s.badge.w, s.badge.h).Text(badgeText(m.mode))
38+ }
27 if n := tag(st, "header", s.header); n.ok {39 if n := tag(st, "header", s.header); n.ok {
28 n.node.Text(m.header)40 n.node.Text(m.header)
29 }41 }

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