detached d8a7032

feat(gui): integrate reviewed capture control panel

commit
d8a7032cc5117629c03d808d3839b4ad7907fab8
author
brice · · 2026-09-30 08:32 UTC
committer
brice · · 2026-09-30 14:48 UTC
merge of
b617195 + 4c2a7d6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1280 -0
A internal/adapters/gui/form.go +187 -0
@@ -0,0 +1,187 @@
1+package gui
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Form limits. Dimension, frame-size and fps bounds come from ports so the
14+// panel cannot accept what the capture pipeline rejects.
15+const maxDuration = 24 * time.Hour
16+
17+// Validation messages are built once, so status rendering never allocates.
18+var (
19+ errOutput = errors.New("! select an output")
20+ errMode = errors.New("! select screenshot or record")
21+ errPath = errors.New("! file must name a path (not empty, not a directory)")
22+ errRegion = fmt.Errorf("! region must be X,Y,WxH with X,Y >= 0, W,H > 0 and X+W, Y+H <= %d, or empty", ports.MaxDimension)
23+ errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS)
24+ errSize = fmt.Errorf("! resolution must be WxH with both from 1 to %d, or empty for source size", ports.MaxDimension)
25+ errSizeOdd = errors.New("! resolution width and height must be even (video encoding)")
26+ errSizeSize = fmt.Errorf("! resolution is too large: at most %d MiB per frame", ports.MaxFrameBytes>>20)
27+ errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
28+
29+ fpsPlaceholder = "1-" + strconv.Itoa(ports.MaxFPS)
30+)
31+
32+// parse validates the form and, on success, stores the result in m.sel. It
33+// reuses m's storage and does not allocate for valid or invalid input.
34+func (m *model) parse() error {
35+ oi := m.outputIndex()
36+ if oi < 0 {
37+ return errOutput
38+ }
39+ path := strings.TrimSpace(m.path)
40+ if path == "" || strings.HasSuffix(path, "/") {
41+ return errPath
42+ }
43+ region, ok := parseRegion(m.region)
44+ if !ok {
45+ return errRegion
46+ }
47+ sel := ports.Selection{
48+ Target: ports.Target{OutputID: m.outputs[oi].id, Region: region},
49+ Path: path,
50+ }
51+ switch m.mode {
52+ case modeScreenshot:
53+ sel.Mode = ports.Screenshot
54+ case modeRecord:
55+ sel.Mode = ports.Record
56+ fps, ok := parseUint(m.fps, ports.MaxFPS)
57+ if !ok || fps < 1 {
58+ return errFPS
59+ }
60+ w, h, ok := parseSize(m.size)
61+ if !ok {
62+ return errSize
63+ }
64+ if err := checkVideoSize(w, h); err != nil {
65+ return err
66+ }
67+ dur, ok := m.parsedDuration()
68+ if !ok {
69+ return errDuration
70+ }
71+ sel.Video = ports.VideoSettings{FPS: fps, Width: w, Height: h}
72+ sel.Duration = dur
73+ default:
74+ return errMode
75+ }
76+ m.sel = sel
77+ return nil
78+}
79+
80+// checkVideoSize applies encoder rules to an explicit video resolution (zero
81+// means source size). Region sizes are not checked: captured physical
82+// dimensions can differ from the logical region by the output scale, so an odd
83+// region does not imply odd video. If the source itself is odd, the user must
84+// set an even resolution; the record note says so.
85+func checkVideoSize(w, h int) error {
86+ if w == 0 {
87+ return nil
88+ }
89+ if w%2 != 0 || h%2 != 0 {
90+ return errSizeOdd
91+ }
92+ if w > ports.MaxFrameBytes/ports.BytesPerPixel/h {
93+ return errSizeSize
94+ }
95+ return nil
96+}
97+
98+// parsedDuration parses m.duration, remembering the last input and result so
99+// an unchanged (even invalid) field costs no allocation per frame.
100+func (m *model) parsedDuration() (time.Duration, bool) {
101+ if !m.durCached || m.durSrc != m.duration {
102+ m.durSrc = m.duration
103+ m.durVal, m.durOK = parseDuration(m.duration)
104+ m.durCached = true
105+ }
106+ return m.durVal, m.durOK
107+}
108+
109+// parseUint parses plain decimal digits no greater than limit.
110+func parseUint(s string, limit int) (int, bool) {
111+ s = strings.TrimSpace(s)
112+ if s == "" {
113+ return 0, false
114+ }
115+ n := 0
116+ for i := 0; i < len(s); i++ {
117+ c := s[i]
118+ if c < '0' || c > '9' {
119+ return 0, false
120+ }
121+ n = n*10 + int(c-'0')
122+ if n > limit {
123+ return 0, false
124+ }
125+ }
126+ return n, true
127+}
128+
129+// parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
130+// It is deliberately stricter than the coordinate bound alone: the far edges
131+// X+W and Y+H must also stay within ports.MaxDimension. Whether the region fits
132+// the chosen output is only known to the capture adapter.
133+func parseRegion(s string) (ports.Region, bool) {
134+ s = strings.TrimSpace(s)
135+ if s == "" {
136+ return ports.Region{}, true
137+ }
138+ xs, rest, ok := strings.Cut(s, ",")
139+ if !ok {
140+ return ports.Region{}, false
141+ }
142+ ys, size, ok := strings.Cut(rest, ",")
143+ if !ok {
144+ return ports.Region{}, false
145+ }
146+ x, ok1 := parseUint(xs, ports.MaxDimension)
147+ y, ok2 := parseUint(ys, ports.MaxDimension)
148+ w, h, ok3 := parseSize(size)
149+ if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension {
150+ return ports.Region{}, false
151+ }
152+ return ports.Region{X: x, Y: y, Width: w, Height: h}, true
153+}
154+
155+// parseSize parses "WxH" with both positive, or empty as 0x0.
156+func parseSize(s string) (w, h int, ok bool) {
157+ s = strings.TrimSpace(s)
158+ if s == "" {
159+ return 0, 0, true
160+ }
161+ i := strings.IndexAny(s, "xX")
162+ if i < 0 {
163+ return 0, 0, false
164+ }
165+ w, ok1 := parseUint(s[:i], ports.MaxDimension)
166+ h, ok2 := parseUint(s[i+1:], ports.MaxDimension)
167+ if !ok1 || !ok2 || w < 1 || h < 1 {
168+ return 0, 0, false
169+ }
170+ return w, h, true
171+}
172+
173+// parseDuration accepts whole seconds or a Go duration; empty means zero.
174+func parseDuration(s string) (time.Duration, bool) {
175+ s = strings.TrimSpace(s)
176+ if s == "" {
177+ return 0, true
178+ }
179+ if n, ok := parseUint(s, int(maxDuration/time.Second)); ok {
180+ return time.Duration(n) * time.Second, true
181+ }
182+ d, err := time.ParseDuration(s)
183+ if err != nil || d < 0 || d > maxDuration {
184+ return 0, false
185+ }
186+ return d, true
187+}
@@ -0,0 +1,187 @@
1+package gui
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// Form limits. Dimension, frame-size and fps bounds come from ports so the
14+// panel cannot accept what the capture pipeline rejects.
15+const maxDuration = 24 * time.Hour
16+
17+// Validation messages are built once, so status rendering never allocates.
18+var (
19+ errOutput = errors.New("! select an output")
20+ errMode = errors.New("! select screenshot or record")
21+ errPath = errors.New("! file must name a path (not empty, not a directory)")
22+ errRegion = fmt.Errorf("! region must be X,Y,WxH with X,Y >= 0, W,H > 0 and X+W, Y+H <= %d, or empty", ports.MaxDimension)
23+ errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS)
24+ errSize = fmt.Errorf("! resolution must be WxH with both from 1 to %d, or empty for source size", ports.MaxDimension)
25+ errSizeOdd = errors.New("! resolution width and height must be even (video encoding)")
26+ errSizeSize = fmt.Errorf("! resolution is too large: at most %d MiB per frame", ports.MaxFrameBytes>>20)
27+ errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
28+
29+ fpsPlaceholder = "1-" + strconv.Itoa(ports.MaxFPS)
30+)
31+
32+// parse validates the form and, on success, stores the result in m.sel. It
33+// reuses m's storage and does not allocate for valid or invalid input.
34+func (m *model) parse() error {
35+ oi := m.outputIndex()
36+ if oi < 0 {
37+ return errOutput
38+ }
39+ path := strings.TrimSpace(m.path)
40+ if path == "" || strings.HasSuffix(path, "/") {
41+ return errPath
42+ }
43+ region, ok := parseRegion(m.region)
44+ if !ok {
45+ return errRegion
46+ }
47+ sel := ports.Selection{
48+ Target: ports.Target{OutputID: m.outputs[oi].id, Region: region},
49+ Path: path,
50+ }
51+ switch m.mode {
52+ case modeScreenshot:
53+ sel.Mode = ports.Screenshot
54+ case modeRecord:
55+ sel.Mode = ports.Record
56+ fps, ok := parseUint(m.fps, ports.MaxFPS)
57+ if !ok || fps < 1 {
58+ return errFPS
59+ }
60+ w, h, ok := parseSize(m.size)
61+ if !ok {
62+ return errSize
63+ }
64+ if err := checkVideoSize(w, h); err != nil {
65+ return err
66+ }
67+ dur, ok := m.parsedDuration()
68+ if !ok {
69+ return errDuration
70+ }
71+ sel.Video = ports.VideoSettings{FPS: fps, Width: w, Height: h}
72+ sel.Duration = dur
73+ default:
74+ return errMode
75+ }
76+ m.sel = sel
77+ return nil
78+}
79+
80+// checkVideoSize applies encoder rules to an explicit video resolution (zero
81+// means source size). Region sizes are not checked: captured physical
82+// dimensions can differ from the logical region by the output scale, so an odd
83+// region does not imply odd video. If the source itself is odd, the user must
84+// set an even resolution; the record note says so.
85+func checkVideoSize(w, h int) error {
86+ if w == 0 {
87+ return nil
88+ }
89+ if w%2 != 0 || h%2 != 0 {
90+ return errSizeOdd
91+ }
92+ if w > ports.MaxFrameBytes/ports.BytesPerPixel/h {
93+ return errSizeSize
94+ }
95+ return nil
96+}
97+
98+// parsedDuration parses m.duration, remembering the last input and result so
99+// an unchanged (even invalid) field costs no allocation per frame.
100+func (m *model) parsedDuration() (time.Duration, bool) {
101+ if !m.durCached || m.durSrc != m.duration {
102+ m.durSrc = m.duration
103+ m.durVal, m.durOK = parseDuration(m.duration)
104+ m.durCached = true
105+ }
106+ return m.durVal, m.durOK
107+}
108+
109+// parseUint parses plain decimal digits no greater than limit.
110+func parseUint(s string, limit int) (int, bool) {
111+ s = strings.TrimSpace(s)
112+ if s == "" {
113+ return 0, false
114+ }
115+ n := 0
116+ for i := 0; i < len(s); i++ {
117+ c := s[i]
118+ if c < '0' || c > '9' {
119+ return 0, false
120+ }
121+ n = n*10 + int(c-'0')
122+ if n > limit {
123+ return 0, false
124+ }
125+ }
126+ return n, true
127+}
128+
129+// parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
130+// It is deliberately stricter than the coordinate bound alone: the far edges
131+// X+W and Y+H must also stay within ports.MaxDimension. Whether the region fits
132+// the chosen output is only known to the capture adapter.
133+func parseRegion(s string) (ports.Region, bool) {
134+ s = strings.TrimSpace(s)
135+ if s == "" {
136+ return ports.Region{}, true
137+ }
138+ xs, rest, ok := strings.Cut(s, ",")
139+ if !ok {
140+ return ports.Region{}, false
141+ }
142+ ys, size, ok := strings.Cut(rest, ",")
143+ if !ok {
144+ return ports.Region{}, false
145+ }
146+ x, ok1 := parseUint(xs, ports.MaxDimension)
147+ y, ok2 := parseUint(ys, ports.MaxDimension)
148+ w, h, ok3 := parseSize(size)
149+ if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension {
150+ return ports.Region{}, false
151+ }
152+ return ports.Region{X: x, Y: y, Width: w, Height: h}, true
153+}
154+
155+// parseSize parses "WxH" with both positive, or empty as 0x0.
156+func parseSize(s string) (w, h int, ok bool) {
157+ s = strings.TrimSpace(s)
158+ if s == "" {
159+ return 0, 0, true
160+ }
161+ i := strings.IndexAny(s, "xX")
162+ if i < 0 {
163+ return 0, 0, false
164+ }
165+ w, ok1 := parseUint(s[:i], ports.MaxDimension)
166+ h, ok2 := parseUint(s[i+1:], ports.MaxDimension)
167+ if !ok1 || !ok2 || w < 1 || h < 1 {
168+ return 0, 0, false
169+ }
170+ return w, h, true
171+}
172+
173+// parseDuration accepts whole seconds or a Go duration; empty means zero.
174+func parseDuration(s string) (time.Duration, bool) {
175+ s = strings.TrimSpace(s)
176+ if s == "" {
177+ return 0, true
178+ }
179+ if n, ok := parseUint(s, int(maxDuration/time.Second)); ok {
180+ return time.Duration(n) * time.Second, true
181+ }
182+ d, err := time.ParseDuration(s)
183+ if err != nil || d < 0 || d > maxDuration {
184+ return 0, false
185+ }
186+ return d, true
187+}
A internal/adapters/gui/form_test.go +234 -0
@@ -0,0 +1,234 @@
1+package gui
2+
3+import (
4+ "strconv"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+var testOutputs = []ports.Output{
15+ {ID: 7, Name: "DP-1", Width: 2560, Height: 1440, Scale: 1},
16+ {ID: 9, Name: "", Width: 1920, Height: 1080, Scale: 2},
17+ {ID: 11},
18+}
19+
20+func newTestModel(t testing.TB) *model {
21+ t.Helper()
22+ m, err := newModel(testOutputs, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
23+ require.NoError(t, err)
24+ return m
25+}
26+
27+func TestParseScreenshotDefaults(t *testing.T) {
28+ m := newTestModel(t)
29+ require.NoError(t, m.parse())
30+ assert.Equal(t, ports.Selection{
31+ Mode: ports.Screenshot,
32+ Target: ports.Target{OutputID: 7},
33+ Path: "nefercap-20260102-030405.png",
34+ }, m.sel)
35+}
36+
37+func TestParseRecordDefaults(t *testing.T) {
38+ m := newTestModel(t)
39+ m.mode = modeRecord
40+ require.NoError(t, m.parse())
41+ assert.Equal(t, ports.Selection{
42+ Mode: ports.Record,
43+ Target: ports.Target{OutputID: 7},
44+ Path: "nefercap-20260102-030405.png",
45+ Video: ports.VideoSettings{FPS: 30},
46+ Duration: 10 * time.Second,
47+ }, m.sel)
48+}
49+
50+func TestParseRecordFull(t *testing.T) {
51+ m := newTestModel(t)
52+ m.mode = modeRecord
53+ m.output = m.outputs[1].key
54+ m.path = " out.mp4 "
55+ m.region = " 10, 20, 640x480 "
56+ m.fps = "60"
57+ m.size = "1280X720"
58+ m.duration = "1m30s"
59+ require.NoError(t, m.parse())
60+ assert.Equal(t, ports.Selection{
61+ Mode: ports.Record,
62+ Target: ports.Target{OutputID: 9, Region: ports.Region{X: 10, Y: 20, Width: 640, Height: 480}},
63+ Path: "out.mp4",
64+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720},
65+ Duration: 90 * time.Second,
66+ }, m.sel)
67+}
68+
69+func TestParseInvalid(t *testing.T) {
70+ tests := []struct {
71+ name string
72+ edit func(*model)
73+ want error
74+ }{
75+ {"unknown output", func(m *model) { m.output = "nope" }, errOutput},
76+ {"empty path", func(m *model) { m.path = " " }, errPath},
77+ {"directory path", func(m *model) { m.path = "dir/" }, errPath},
78+ {"bad mode", func(m *model) { m.mode = "" }, errMode},
79+ {"region fields", func(m *model) { m.region = "1,2" }, errRegion},
80+ {"region no comma", func(m *model) { m.region = "1x2" }, errRegion},
81+ {"region zero width", func(m *model) { m.region = "0,0,0x5" }, errRegion},
82+ {"region zero height", func(m *model) { m.region = "0,0,5x0" }, errRegion},
83+ {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
84+ {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
85+ {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
86+ {"region far edge x", func(m *model) { m.region = "16000,0,385x5" }, errRegion},
87+ {"region far edge y", func(m *model) { m.region = "0,16000,5x385" }, errRegion},
88+ {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
89+ {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
90+ {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
91+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS},
92+ {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
93+ {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
94+ {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
95+ {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
96+ {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
97+ {"size odd width", func(m *model) { m.mode = modeRecord; m.size = "1281x720" }, errSizeOdd},
98+ {"size odd height", func(m *model) { m.mode = modeRecord; m.size = "1280x721" }, errSizeOdd},
99+ {"size over frame bytes", func(m *model) { m.mode = modeRecord; m.size = "16384x16384" }, errSizeSize},
100+ {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
101+ {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
102+ {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
103+ {"duration huge seconds", func(m *model) { m.mode = modeRecord; m.duration = "999999999999999999999" }, errDuration},
104+ }
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ m := newTestModel(t)
108+ tt.edit(m)
109+ assert.ErrorIs(t, m.parse(), tt.want)
110+ })
111+ }
112+}
113+
114+func TestFPSBoundIsPortsMax(t *testing.T) {
115+ m := newTestModel(t)
116+ m.mode = modeRecord
117+ m.fps = strconv.Itoa(ports.MaxFPS)
118+ require.NoError(t, m.parse())
119+ assert.Equal(t, ports.MaxFPS, m.sel.Video.FPS)
120+ m.fps = strconv.Itoa(ports.MaxFPS + 1)
121+ assert.ErrorIs(t, m.parse(), errFPS)
122+ assert.Contains(t, errFPS.Error(), strconv.Itoa(ports.MaxFPS))
123+ assert.Equal(t, "1-"+strconv.Itoa(ports.MaxFPS), fpsPlaceholder)
124+}
125+
126+func TestOddRegionAllowedEvenSizeChecked(t *testing.T) {
127+ m := newTestModel(t)
128+ m.mode = modeRecord
129+ m.region = "1,3,641x481"
130+ require.NoError(t, m.parse())
131+ assert.Equal(t, ports.Region{X: 1, Y: 3, Width: 641, Height: 481}, m.sel.Target.Region)
132+ m.mode = modeScreenshot
133+ m.size = "1281x721"
134+ require.NoError(t, m.parse(), "screenshot ignores video size")
135+}
136+
137+func TestVideoSizeAtFrameLimit(t *testing.T) {
138+ m := newTestModel(t)
139+ m.mode = modeRecord
140+ m.size = "16384x4096"
141+ require.NoError(t, m.parse())
142+ m.size = "16384x4098"
143+ assert.ErrorIs(t, m.parse(), errSizeSize)
144+}
145+
146+func TestRegionAtMaxDimension(t *testing.T) {
147+ m := newTestModel(t)
148+ m.region = "0,0,16384x16384"
149+ require.NoError(t, m.parse())
150+ m.region = "1,0,16384x1"
151+ assert.ErrorIs(t, m.parse(), errRegion)
152+}
153+
154+func TestScreenshotIgnoresRecordFields(t *testing.T) {
155+ m := newTestModel(t)
156+ m.fps, m.size, m.duration = "bad", "bad", "bad"
157+ require.NoError(t, m.parse())
158+ assert.Equal(t, ports.VideoSettings{}, m.sel.Video)
159+ assert.Zero(t, m.sel.Duration)
160+}
161+
162+func TestInvalidParseKeepsPreviousSelection(t *testing.T) {
163+ m := newTestModel(t)
164+ require.NoError(t, m.parse())
165+ want := m.sel
166+ m.path = ""
167+ require.Error(t, m.parse())
168+ assert.Equal(t, want, m.sel)
169+}
170+
171+func TestParseDuration(t *testing.T) {
172+ for in, want := range map[string]time.Duration{
173+ "": 0, "0": 0, "5": 5 * time.Second, " 90 ": 90 * time.Second,
174+ "1.5s": 1500 * time.Millisecond, "2m": 2 * time.Minute, "24h": 24 * time.Hour, "86400": 24 * time.Hour,
175+ } {
176+ got, ok := parseDuration(in)
177+ assert.True(t, ok, in)
178+ assert.Equal(t, want, got, in)
179+ }
180+}
181+
182+func TestParseUintBounds(t *testing.T) {
183+ n, ok := parseUint("16384", ports.MaxDimension)
184+ assert.True(t, ok)
185+ assert.Equal(t, 16384, n)
186+ _, ok = parseUint("16385", ports.MaxDimension)
187+ assert.False(t, ok)
188+ _, ok = parseUint("", ports.MaxDimension)
189+ assert.False(t, ok)
190+}
191+
192+// The status line runs every frame and must not allocate for valid or invalid
193+// forms: parsing uses only the standard library on the model's own strings.
194+func TestFormAllocations(t *testing.T) {
195+ m := newTestModel(t)
196+ m.mode = modeRecord
197+ m.region = "10,20,640x480"
198+ m.size = "1280x720"
199+ m.duration = "1m30s"
200+ require.NoError(t, m.parse())
201+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
202+ m.size = "1281x720"
203+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "odd size")
204+ m.size = "1280x720"
205+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
206+ m.fps = "abc"
207+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
208+ m.fps = "30"
209+ m.duration = "0"
210+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
211+ m.duration = "soon"
212+ require.ErrorIs(t, m.parse(), errDuration)
213+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid duration")
214+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "invalid duration status")
215+ m.duration = "-5s"
216+ require.ErrorIs(t, m.parse(), errDuration)
217+ m.duration = "1m"
218+ require.NoError(t, m.parse(), "cache follows edits")
219+ assert.Equal(t, time.Minute, m.sel.Duration)
220+}
221+
222+func BenchmarkFormParse(b *testing.B) {
223+ m := newTestModel(b)
224+ m.mode = modeRecord
225+ m.region = "10,20,640x480"
226+ m.size = "1280x720"
227+ m.duration = "1m30s"
228+ b.ReportAllocs()
229+ for b.Loop() {
230+ if err := m.parse(); err != nil {
231+ b.Fatal(err)
232+ }
233+ }
234+}
@@ -0,0 +1,234 @@
1+package gui
2+
3+import (
4+ "strconv"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+var testOutputs = []ports.Output{
15+ {ID: 7, Name: "DP-1", Width: 2560, Height: 1440, Scale: 1},
16+ {ID: 9, Name: "", Width: 1920, Height: 1080, Scale: 2},
17+ {ID: 11},
18+}
19+
20+func newTestModel(t testing.TB) *model {
21+ t.Helper()
22+ m, err := newModel(testOutputs, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
23+ require.NoError(t, err)
24+ return m
25+}
26+
27+func TestParseScreenshotDefaults(t *testing.T) {
28+ m := newTestModel(t)
29+ require.NoError(t, m.parse())
30+ assert.Equal(t, ports.Selection{
31+ Mode: ports.Screenshot,
32+ Target: ports.Target{OutputID: 7},
33+ Path: "nefercap-20260102-030405.png",
34+ }, m.sel)
35+}
36+
37+func TestParseRecordDefaults(t *testing.T) {
38+ m := newTestModel(t)
39+ m.mode = modeRecord
40+ require.NoError(t, m.parse())
41+ assert.Equal(t, ports.Selection{
42+ Mode: ports.Record,
43+ Target: ports.Target{OutputID: 7},
44+ Path: "nefercap-20260102-030405.png",
45+ Video: ports.VideoSettings{FPS: 30},
46+ Duration: 10 * time.Second,
47+ }, m.sel)
48+}
49+
50+func TestParseRecordFull(t *testing.T) {
51+ m := newTestModel(t)
52+ m.mode = modeRecord
53+ m.output = m.outputs[1].key
54+ m.path = " out.mp4 "
55+ m.region = " 10, 20, 640x480 "
56+ m.fps = "60"
57+ m.size = "1280X720"
58+ m.duration = "1m30s"
59+ require.NoError(t, m.parse())
60+ assert.Equal(t, ports.Selection{
61+ Mode: ports.Record,
62+ Target: ports.Target{OutputID: 9, Region: ports.Region{X: 10, Y: 20, Width: 640, Height: 480}},
63+ Path: "out.mp4",
64+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720},
65+ Duration: 90 * time.Second,
66+ }, m.sel)
67+}
68+
69+func TestParseInvalid(t *testing.T) {
70+ tests := []struct {
71+ name string
72+ edit func(*model)
73+ want error
74+ }{
75+ {"unknown output", func(m *model) { m.output = "nope" }, errOutput},
76+ {"empty path", func(m *model) { m.path = " " }, errPath},
77+ {"directory path", func(m *model) { m.path = "dir/" }, errPath},
78+ {"bad mode", func(m *model) { m.mode = "" }, errMode},
79+ {"region fields", func(m *model) { m.region = "1,2" }, errRegion},
80+ {"region no comma", func(m *model) { m.region = "1x2" }, errRegion},
81+ {"region zero width", func(m *model) { m.region = "0,0,0x5" }, errRegion},
82+ {"region zero height", func(m *model) { m.region = "0,0,5x0" }, errRegion},
83+ {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
84+ {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
85+ {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
86+ {"region far edge x", func(m *model) { m.region = "16000,0,385x5" }, errRegion},
87+ {"region far edge y", func(m *model) { m.region = "0,16000,5x385" }, errRegion},
88+ {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
89+ {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
90+ {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
91+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS},
92+ {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
93+ {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
94+ {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
95+ {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
96+ {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
97+ {"size odd width", func(m *model) { m.mode = modeRecord; m.size = "1281x720" }, errSizeOdd},
98+ {"size odd height", func(m *model) { m.mode = modeRecord; m.size = "1280x721" }, errSizeOdd},
99+ {"size over frame bytes", func(m *model) { m.mode = modeRecord; m.size = "16384x16384" }, errSizeSize},
100+ {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
101+ {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
102+ {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
103+ {"duration huge seconds", func(m *model) { m.mode = modeRecord; m.duration = "999999999999999999999" }, errDuration},
104+ }
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ m := newTestModel(t)
108+ tt.edit(m)
109+ assert.ErrorIs(t, m.parse(), tt.want)
110+ })
111+ }
112+}
113+
114+func TestFPSBoundIsPortsMax(t *testing.T) {
115+ m := newTestModel(t)
116+ m.mode = modeRecord
117+ m.fps = strconv.Itoa(ports.MaxFPS)
118+ require.NoError(t, m.parse())
119+ assert.Equal(t, ports.MaxFPS, m.sel.Video.FPS)
120+ m.fps = strconv.Itoa(ports.MaxFPS + 1)
121+ assert.ErrorIs(t, m.parse(), errFPS)
122+ assert.Contains(t, errFPS.Error(), strconv.Itoa(ports.MaxFPS))
123+ assert.Equal(t, "1-"+strconv.Itoa(ports.MaxFPS), fpsPlaceholder)
124+}
125+
126+func TestOddRegionAllowedEvenSizeChecked(t *testing.T) {
127+ m := newTestModel(t)
128+ m.mode = modeRecord
129+ m.region = "1,3,641x481"
130+ require.NoError(t, m.parse())
131+ assert.Equal(t, ports.Region{X: 1, Y: 3, Width: 641, Height: 481}, m.sel.Target.Region)
132+ m.mode = modeScreenshot
133+ m.size = "1281x721"
134+ require.NoError(t, m.parse(), "screenshot ignores video size")
135+}
136+
137+func TestVideoSizeAtFrameLimit(t *testing.T) {
138+ m := newTestModel(t)
139+ m.mode = modeRecord
140+ m.size = "16384x4096"
141+ require.NoError(t, m.parse())
142+ m.size = "16384x4098"
143+ assert.ErrorIs(t, m.parse(), errSizeSize)
144+}
145+
146+func TestRegionAtMaxDimension(t *testing.T) {
147+ m := newTestModel(t)
148+ m.region = "0,0,16384x16384"
149+ require.NoError(t, m.parse())
150+ m.region = "1,0,16384x1"
151+ assert.ErrorIs(t, m.parse(), errRegion)
152+}
153+
154+func TestScreenshotIgnoresRecordFields(t *testing.T) {
155+ m := newTestModel(t)
156+ m.fps, m.size, m.duration = "bad", "bad", "bad"
157+ require.NoError(t, m.parse())
158+ assert.Equal(t, ports.VideoSettings{}, m.sel.Video)
159+ assert.Zero(t, m.sel.Duration)
160+}
161+
162+func TestInvalidParseKeepsPreviousSelection(t *testing.T) {
163+ m := newTestModel(t)
164+ require.NoError(t, m.parse())
165+ want := m.sel
166+ m.path = ""
167+ require.Error(t, m.parse())
168+ assert.Equal(t, want, m.sel)
169+}
170+
171+func TestParseDuration(t *testing.T) {
172+ for in, want := range map[string]time.Duration{
173+ "": 0, "0": 0, "5": 5 * time.Second, " 90 ": 90 * time.Second,
174+ "1.5s": 1500 * time.Millisecond, "2m": 2 * time.Minute, "24h": 24 * time.Hour, "86400": 24 * time.Hour,
175+ } {
176+ got, ok := parseDuration(in)
177+ assert.True(t, ok, in)
178+ assert.Equal(t, want, got, in)
179+ }
180+}
181+
182+func TestParseUintBounds(t *testing.T) {
183+ n, ok := parseUint("16384", ports.MaxDimension)
184+ assert.True(t, ok)
185+ assert.Equal(t, 16384, n)
186+ _, ok = parseUint("16385", ports.MaxDimension)
187+ assert.False(t, ok)
188+ _, ok = parseUint("", ports.MaxDimension)
189+ assert.False(t, ok)
190+}
191+
192+// The status line runs every frame and must not allocate for valid or invalid
193+// forms: parsing uses only the standard library on the model's own strings.
194+func TestFormAllocations(t *testing.T) {
195+ m := newTestModel(t)
196+ m.mode = modeRecord
197+ m.region = "10,20,640x480"
198+ m.size = "1280x720"
199+ m.duration = "1m30s"
200+ require.NoError(t, m.parse())
201+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
202+ m.size = "1281x720"
203+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "odd size")
204+ m.size = "1280x720"
205+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
206+ m.fps = "abc"
207+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
208+ m.fps = "30"
209+ m.duration = "0"
210+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
211+ m.duration = "soon"
212+ require.ErrorIs(t, m.parse(), errDuration)
213+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid duration")
214+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "invalid duration status")
215+ m.duration = "-5s"
216+ require.ErrorIs(t, m.parse(), errDuration)
217+ m.duration = "1m"
218+ require.NoError(t, m.parse(), "cache follows edits")
219+ assert.Equal(t, time.Minute, m.sel.Duration)
220+}
221+
222+func BenchmarkFormParse(b *testing.B) {
223+ m := newTestModel(b)
224+ m.mode = modeRecord
225+ m.region = "10,20,640x480"
226+ m.size = "1280x720"
227+ m.duration = "1m30s"
228+ b.ReportAllocs()
229+ for b.Loop() {
230+ if err := m.parse(); err != nil {
231+ b.Fatal(err)
232+ }
233+ }
234+}
A internal/adapters/gui/gui.go +139 -0
@@ -0,0 +1,139 @@
1+// Package gui is the NeferGUI control panel adapter: a synchronous window that
2+// returns the user's capture selection before any capture begins.
3+package gui
4+
5+import (
6+ "context"
7+ _ "embed"
8+ "errors"
9+ "fmt"
10+ "os"
11+ "time"
12+
13+ "github.com/bnema/nefergui"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+//go:embed style.css
19+var styleSheet string
20+
21+var errNoOutputs = fmt.Errorf("gui: no outputs to select: %w", ports.ErrOutputNotFound)
22+
23+// Selector shows the control panel. Its zero value is ready to use.
24+type Selector struct{}
25+
26+var _ ports.Selector = (*Selector)(nil)
27+
28+// New returns a control panel selector.
29+func New() *Selector { return &Selector{} }
30+
31+// Select blocks until the user accepts a selection (true), closes the window
32+// (false, nil) or ctx ends or the window system fails (error). The window is
33+// destroyed by the panel's own Wayland session before an accepted selection is
34+// returned. The compositor may still process that teardown after Select
35+// returns, so a caller that must not capture the panel should confirm through
36+// a fresh capture connection before capturing. Select must be called from one
37+// goroutine at a time.
38+func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
39+ if err := ctx.Err(); err != nil {
40+ return ports.Selection{}, false, err
41+ }
42+ m, err := newModel(outputs, time.Now())
43+ if err != nil {
44+ return ports.Selection{}, false, err
45+ }
46+ sheet, removeSheet, err := writeStyleSheet()
47+ if err != nil {
48+ return ports.Selection{}, false, err
49+ }
50+ defer removeSheet()
51+
52+ // The child context ends Run once the user decides; the parent stays
53+ // authoritative for real cancellation.
54+ child, cancel := context.WithCancel(ctx)
55+ defer cancel()
56+ m.cancel = cancel
57+ runErr := nefergui.Run(child, m, view,
58+ nefergui.Title("NeferCap"),
59+ nefergui.Size(640, m.windowHeight()),
60+ nefergui.Styles(sheet),
61+ )
62+ return m.result(ctx, runErr)
63+}
64+
65+// result maps Run's outcome. The parent context is checked first: if it has
66+// ended, nothing is accepted and its error is returned, joined with any real
67+// Run error. Otherwise a Canceled error caused by our own accept or dismiss is
68+// not a failure, but any other error (including one joined with it, such as a
69+// surface Close failure) is reported and rejects the selection.
70+func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
71+ if perr := parent.Err(); perr != nil {
72+ if rest := dropErrors(runErr, context.Canceled, perr); rest != nil {
73+ return ports.Selection{}, false, errors.Join(perr, rest)
74+ }
75+ return ports.Selection{}, false, perr
76+ }
77+ if m.accepted || m.closed {
78+ runErr = dropErrors(runErr, context.Canceled)
79+ }
80+ if runErr != nil {
81+ return ports.Selection{}, false, runErr
82+ }
83+ if m.accepted {
84+ return m.sel, true, nil
85+ }
86+ return ports.Selection{}, false, nil
87+}
88+
89+// dropErrors removes leaves equal to any target from err and from joined
90+// errors, keeping everything else. It returns nil if nothing real remains.
91+func dropErrors(err error, targets ...error) error {
92+ if err == nil {
93+ return nil
94+ }
95+ if j, ok := err.(interface{ Unwrap() []error }); ok {
96+ var keep []error
97+ for _, e := range j.Unwrap() {
98+ if e = dropErrors(e, targets...); e != nil {
99+ keep = append(keep, e)
100+ }
101+ }
102+ switch len(keep) {
103+ case 0:
104+ return nil
105+ case 1:
106+ return keep[0]
107+ }
108+ return errors.Join(keep...)
109+ }
110+ for _, t := range targets {
111+ if err == t {
112+ return nil
113+ }
114+ }
115+ return err
116+}
117+
118+// writeStyleSheet stores the embedded stylesheet in a private temporary file
119+// because NeferGUI loads styles by path. It is read once when Run starts, so
120+// removal after Run returns is enough; a removal failure leaves only a small
121+// file in the temporary directory and is not worth failing a capture over.
122+func writeStyleSheet() (path string, remove func(), err error) {
123+ f, err := os.CreateTemp("", "nefercap-gui-*.css")
124+ if err != nil {
125+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
126+ }
127+ path = f.Name()
128+ remove = func() { _ = os.Remove(path) }
129+ if _, err := f.WriteString(styleSheet); err != nil {
130+ err = errors.Join(err, f.Close())
131+ remove()
132+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
133+ }
134+ if err := f.Close(); err != nil {
135+ remove()
136+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
137+ }
138+ return path, remove, nil
139+}
@@ -0,0 +1,139 @@
1+// Package gui is the NeferGUI control panel adapter: a synchronous window that
2+// returns the user's capture selection before any capture begins.
3+package gui
4+
5+import (
6+ "context"
7+ _ "embed"
8+ "errors"
9+ "fmt"
10+ "os"
11+ "time"
12+
13+ "github.com/bnema/nefergui"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+//go:embed style.css
19+var styleSheet string
20+
21+var errNoOutputs = fmt.Errorf("gui: no outputs to select: %w", ports.ErrOutputNotFound)
22+
23+// Selector shows the control panel. Its zero value is ready to use.
24+type Selector struct{}
25+
26+var _ ports.Selector = (*Selector)(nil)
27+
28+// New returns a control panel selector.
29+func New() *Selector { return &Selector{} }
30+
31+// Select blocks until the user accepts a selection (true), closes the window
32+// (false, nil) or ctx ends or the window system fails (error). The window is
33+// destroyed by the panel's own Wayland session before an accepted selection is
34+// returned. The compositor may still process that teardown after Select
35+// returns, so a caller that must not capture the panel should confirm through
36+// a fresh capture connection before capturing. Select must be called from one
37+// goroutine at a time.
38+func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
39+ if err := ctx.Err(); err != nil {
40+ return ports.Selection{}, false, err
41+ }
42+ m, err := newModel(outputs, time.Now())
43+ if err != nil {
44+ return ports.Selection{}, false, err
45+ }
46+ sheet, removeSheet, err := writeStyleSheet()
47+ if err != nil {
48+ return ports.Selection{}, false, err
49+ }
50+ defer removeSheet()
51+
52+ // The child context ends Run once the user decides; the parent stays
53+ // authoritative for real cancellation.
54+ child, cancel := context.WithCancel(ctx)
55+ defer cancel()
56+ m.cancel = cancel
57+ runErr := nefergui.Run(child, m, view,
58+ nefergui.Title("NeferCap"),
59+ nefergui.Size(640, m.windowHeight()),
60+ nefergui.Styles(sheet),
61+ )
62+ return m.result(ctx, runErr)
63+}
64+
65+// result maps Run's outcome. The parent context is checked first: if it has
66+// ended, nothing is accepted and its error is returned, joined with any real
67+// Run error. Otherwise a Canceled error caused by our own accept or dismiss is
68+// not a failure, but any other error (including one joined with it, such as a
69+// surface Close failure) is reported and rejects the selection.
70+func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
71+ if perr := parent.Err(); perr != nil {
72+ if rest := dropErrors(runErr, context.Canceled, perr); rest != nil {
73+ return ports.Selection{}, false, errors.Join(perr, rest)
74+ }
75+ return ports.Selection{}, false, perr
76+ }
77+ if m.accepted || m.closed {
78+ runErr = dropErrors(runErr, context.Canceled)
79+ }
80+ if runErr != nil {
81+ return ports.Selection{}, false, runErr
82+ }
83+ if m.accepted {
84+ return m.sel, true, nil
85+ }
86+ return ports.Selection{}, false, nil
87+}
88+
89+// dropErrors removes leaves equal to any target from err and from joined
90+// errors, keeping everything else. It returns nil if nothing real remains.
91+func dropErrors(err error, targets ...error) error {
92+ if err == nil {
93+ return nil
94+ }
95+ if j, ok := err.(interface{ Unwrap() []error }); ok {
96+ var keep []error
97+ for _, e := range j.Unwrap() {
98+ if e = dropErrors(e, targets...); e != nil {
99+ keep = append(keep, e)
100+ }
101+ }
102+ switch len(keep) {
103+ case 0:
104+ return nil
105+ case 1:
106+ return keep[0]
107+ }
108+ return errors.Join(keep...)
109+ }
110+ for _, t := range targets {
111+ if err == t {
112+ return nil
113+ }
114+ }
115+ return err
116+}
117+
118+// writeStyleSheet stores the embedded stylesheet in a private temporary file
119+// because NeferGUI loads styles by path. It is read once when Run starts, so
120+// removal after Run returns is enough; a removal failure leaves only a small
121+// file in the temporary directory and is not worth failing a capture over.
122+func writeStyleSheet() (path string, remove func(), err error) {
123+ f, err := os.CreateTemp("", "nefercap-gui-*.css")
124+ if err != nil {
125+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
126+ }
127+ path = f.Name()
128+ remove = func() { _ = os.Remove(path) }
129+ if _, err := f.WriteString(styleSheet); err != nil {
130+ err = errors.Join(err, f.Close())
131+ remove()
132+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
133+ }
134+ if err := f.Close(); err != nil {
135+ remove()
136+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
137+ }
138+ return path, remove, nil
139+}
A internal/adapters/gui/gui_test.go +293 -0
@@ -0,0 +1,293 @@
1+package gui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+func TestNewModelLabelsAndDefaults(t *testing.T) {
19+ m := newTestModel(t)
20+ require.Len(t, m.outputs, 3)
21+ assert.Equal(t, "DP-1 2560x1440", m.outputs[0].label)
22+ assert.Equal(t, "output 9 1920x1080 scale 2", m.outputs[1].label)
23+ assert.Equal(t, "output 11", m.outputs[2].label)
24+ assert.Equal(t, m.outputs[0].key, m.output)
25+ assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)
26+ assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)
27+ assert.Equal(t, "30", m.fps)
28+ assert.Equal(t, "10", m.duration)
29+ keys := map[string]bool{}
30+ for _, o := range m.outputs {
31+ assert.False(t, keys[o.key])
32+ keys[o.key] = true
33+ }
34+}
35+
36+func TestNewModelNoOutputs(t *testing.T) {
37+ _, err := newModel(nil, time.Now())
38+ assert.ErrorIs(t, err, ports.ErrOutputNotFound)
39+}
40+
41+func TestSelectRejectsBeforeOpeningWindow(t *testing.T) {
42+ ctx, cancel := context.WithCancel(context.Background())
43+ cancel()
44+ _, ok, err := New().Select(ctx, testOutputs)
45+ assert.False(t, ok)
46+ assert.ErrorIs(t, err, context.Canceled)
47+
48+ _, ok, err = New().Select(context.Background(), nil)
49+ assert.False(t, ok)
50+ assert.ErrorIs(t, err, ports.ErrOutputNotFound)
51+}
52+
53+func TestModeChangeSwapsOnlyUntouchedDefault(t *testing.T) {
54+ m := newTestModel(t)
55+ m.mode = modeRecord
56+ m.modeChanged()
57+ assert.Equal(t, m.defaultRec, m.path)
58+ m.mode = modeScreenshot
59+ m.modeChanged()
60+ assert.Equal(t, m.defaultShot, m.path)
61+
62+ m.path = "mine.png"
63+ m.mode = modeRecord
64+ m.modeChanged()
65+ assert.Equal(t, "mine.png", m.path)
66+}
67+
68+func TestSubmitAccepts(t *testing.T) {
69+ m := newTestModel(t)
70+ ctx, cancel := context.WithCancel(context.Background())
71+ defer cancel()
72+ m.cancel = cancel
73+ m.path = filepath.Join(t.TempDir(), "shot.png")
74+ m.submit()
75+ assert.True(t, m.accepted)
76+ assert.Error(t, ctx.Err())
77+ assert.Equal(t, m.path, m.sel.Path)
78+ assert.Equal(t, ports.Screenshot, m.sel.Mode)
79+
80+ // Later dismissal cannot revoke an accepted selection.
81+ m.dismiss()
82+ assert.False(t, m.closed)
83+}
84+
85+func TestSubmitInvalidStaysOpen(t *testing.T) {
86+ m := newTestModel(t)
87+ ctx, cancel := context.WithCancel(context.Background())
88+ defer cancel()
89+ m.cancel = cancel
90+ m.path = ""
91+ m.submit()
92+ assert.False(t, m.accepted)
93+ assert.NoError(t, ctx.Err())
94+}
95+
96+func TestSubmitExistingPathStaysOpen(t *testing.T) {
97+ m := newTestModel(t)
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+ m.cancel = cancel
101+ m.path = filepath.Join(t.TempDir(), "taken.png")
102+ require.NoError(t, os.WriteFile(m.path, nil, 0o600))
103+ m.submit()
104+ assert.False(t, m.accepted)
105+ assert.NoError(t, ctx.Err())
106+ text, submittable, ok := m.status()
107+ assert.Equal(t, noticeExists, text)
108+ assert.True(t, submittable)
109+ assert.False(t, ok)
110+
111+ m.pathChanged()
112+ text, submittable, ok = m.status()
113+ assert.Equal(t, statusReady, text)
114+ assert.True(t, submittable)
115+ assert.True(t, ok)
116+}
117+
118+func TestStatusInvalid(t *testing.T) {
119+ m := newTestModel(t)
120+ m.region = "bad"
121+ text, submittable, ok := m.status()
122+ assert.Equal(t, errRegion.Error(), text)
123+ assert.False(t, submittable)
124+ assert.False(t, ok)
125+}
126+
127+func TestDismiss(t *testing.T) {
128+ m := newTestModel(t)
129+ ctx, cancel := context.WithCancel(context.Background())
130+ defer cancel()
131+ m.cancel = cancel
132+ m.dismiss()
133+ assert.True(t, m.closed)
134+ assert.Error(t, ctx.Err())
135+ m.submit()
136+ assert.False(t, m.accepted)
137+}
138+
139+func TestResult(t *testing.T) {
140+ closeErr := errors.New("close surface")
141+ canceledParent, cancelParent := context.WithCancel(context.Background())
142+ cancelParent()
143+ tests := []struct {
144+ name string
145+ parent context.Context
146+ accepted bool
147+ closed bool
148+ runErr error
149+ wantOK bool
150+ wantErr error
151+ wantNoErr bool
152+ wantNotErr error
153+ }{
154+ {name: "accepted canceled child", parent: context.Background(), accepted: true, runErr: context.Canceled, wantOK: true, wantNoErr: true},
155+ {name: "accepted nil run", parent: context.Background(), accepted: true, wantOK: true, wantNoErr: true},
156+ {name: "closed by button", parent: context.Background(), closed: true, runErr: context.Canceled, wantNoErr: true},
157+ {name: "closed by window", parent: context.Background(), wantNoErr: true},
158+ {name: "accepted but close failed", parent: context.Background(), accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr, wantNotErr: context.Canceled},
159+ {name: "accepted but run failed", parent: context.Background(), accepted: true, runErr: closeErr, wantErr: closeErr},
160+ {name: "closed with failure", parent: context.Background(), closed: true, runErr: fmt.Errorf("x: %w", closeErr), wantErr: closeErr},
161+ {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},
162+ {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},
163+ {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},
164+ {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled},
165+ {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
166+ {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
167+ }
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ m := newTestModel(t)
171+ m.accepted, m.closed = tt.accepted, tt.closed
172+ m.sel = ports.Selection{Mode: ports.Record, Path: "x.mp4"}
173+ sel, ok, err := m.result(tt.parent, tt.runErr)
174+ assert.Equal(t, tt.wantOK, ok)
175+ if tt.wantOK {
176+ assert.Equal(t, m.sel, sel)
177+ } else {
178+ assert.Equal(t, ports.Selection{}, sel)
179+ }
180+ if tt.wantNoErr {
181+ assert.NoError(t, err)
182+ }
183+ if tt.wantErr != nil {
184+ assert.ErrorIs(t, err, tt.wantErr)
185+ }
186+ if tt.wantNotErr != nil {
187+ assert.NotErrorIs(t, err, tt.wantNotErr)
188+ }
189+ })
190+ }
191+}
192+
193+// A parent deadline or cancel with only cancellation noise from Run reports
194+// the parent's own error bare, so callers can exit cleanly on it.
195+func TestResultReturnsBareParentError(t *testing.T) {
196+ m := newTestModel(t)
197+ parent, cancel := context.WithCancel(context.Background())
198+ cancel()
199+ _, _, err := m.result(parent, errors.Join(context.Canceled, context.Canceled))
200+ assert.True(t, err == context.Canceled, "bare canceled error")
201+
202+ deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
203+ defer stop()
204+ _, _, err = m.result(deadline, context.DeadlineExceeded)
205+ assert.True(t, err == context.DeadlineExceeded, "bare deadline error")
206+}
207+
208+func TestDropErrors(t *testing.T) {
209+ real := errors.New("real")
210+ assert.NoError(t, dropErrors(nil, context.Canceled))
211+ assert.NoError(t, dropErrors(context.Canceled, context.Canceled))
212+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled))
213+ assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled))
214+ wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
215+ assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours")
216+ both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled)
217+ assert.ErrorIs(t, both, real)
218+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
219+}
220+
221+func TestCheckPath(t *testing.T) {
222+ dir := t.TempDir()
223+ file := filepath.Join(dir, "taken.png")
224+ require.NoError(t, os.WriteFile(file, nil, 0o600))
225+ assert.Empty(t, checkPath(filepath.Join(dir, "new.png")))
226+ assert.Empty(t, checkPath(filepath.Join(dir, "new.mp4")), "extension does not dictate format")
227+ assert.Equal(t, noticeExists, checkPath(file))
228+ assert.Equal(t, noticeDir, checkPath(filepath.Join(dir, "missing", "new.png")))
229+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "new.png")), "parent is a file")
230+ assert.Empty(t, checkPath("relative-new-name.png"))
231+
232+ link := filepath.Join(dir, "dangling.png")
233+ require.NoError(t, os.Symlink(filepath.Join(dir, "nowhere"), link))
234+ assert.Equal(t, noticeExists, checkPath(link), "dangling symlink is not overwritten")
235+
236+ // A path component that is a regular file yields ENOTDIR, not ErrNotExist.
237+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "x", "y.png")))
238+}
239+
240+func TestRecordWithImageExtensionAccepted(t *testing.T) {
241+ m := newTestModel(t)
242+ m.mode = modeRecord
243+ m.path = filepath.Join(t.TempDir(), "clip.png")
244+ require.NoError(t, m.parse())
245+ assert.Equal(t, ports.Record, m.sel.Mode)
246+}
247+
248+func TestWriteStyleSheet(t *testing.T) {
249+ t.Setenv("TMPDIR", t.TempDir())
250+ path, remove, err := writeStyleSheet()
251+ require.NoError(t, err)
252+ data, err := os.ReadFile(path)
253+ require.NoError(t, err)
254+ assert.Equal(t, styleSheet, string(data))
255+ info, err := os.Stat(path)
256+ require.NoError(t, err)
257+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
258+ remove()
259+ _, err = os.Stat(path)
260+ assert.ErrorIs(t, err, os.ErrNotExist)
261+}
262+
263+func TestStyleSheetUsesMonospace(t *testing.T) {
264+ assert.Contains(t, styleSheet, "monospace")
265+}
266+
267+func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) {
268+ assert.Contains(t, styleSheet, "overflow-y: auto")
269+ assert.NotContains(t, styleSheet, "separator")
270+}
271+
272+func TestWriteStyleSheetFailure(t *testing.T) {
273+ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
274+ _, _, err := writeStyleSheet()
275+ assert.Error(t, err)
276+}
277+
278+func TestWindowHeightIsBounded(t *testing.T) {
279+ m := newTestModel(t)
280+ assert.Equal(t, 600+26*3, m.windowHeight())
281+ m.outputs = make([]outputChoice, 100)
282+ assert.Equal(t, 1000, m.windowHeight())
283+}
284+
285+func TestModeChangeNoop(t *testing.T) {
286+ m := newTestModel(t)
287+ m.notice = "keep"
288+ m.modeChanged()
289+ assert.Equal(t, "keep", m.notice)
290+ m.mode = modeRecord
291+ m.modeChanged()
292+ assert.Empty(t, m.notice)
293+}
@@ -0,0 +1,293 @@
1+package gui
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+func TestNewModelLabelsAndDefaults(t *testing.T) {
19+ m := newTestModel(t)
20+ require.Len(t, m.outputs, 3)
21+ assert.Equal(t, "DP-1 2560x1440", m.outputs[0].label)
22+ assert.Equal(t, "output 9 1920x1080 scale 2", m.outputs[1].label)
23+ assert.Equal(t, "output 11", m.outputs[2].label)
24+ assert.Equal(t, m.outputs[0].key, m.output)
25+ assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)
26+ assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)
27+ assert.Equal(t, "30", m.fps)
28+ assert.Equal(t, "10", m.duration)
29+ keys := map[string]bool{}
30+ for _, o := range m.outputs {
31+ assert.False(t, keys[o.key])
32+ keys[o.key] = true
33+ }
34+}
35+
36+func TestNewModelNoOutputs(t *testing.T) {
37+ _, err := newModel(nil, time.Now())
38+ assert.ErrorIs(t, err, ports.ErrOutputNotFound)
39+}
40+
41+func TestSelectRejectsBeforeOpeningWindow(t *testing.T) {
42+ ctx, cancel := context.WithCancel(context.Background())
43+ cancel()
44+ _, ok, err := New().Select(ctx, testOutputs)
45+ assert.False(t, ok)
46+ assert.ErrorIs(t, err, context.Canceled)
47+
48+ _, ok, err = New().Select(context.Background(), nil)
49+ assert.False(t, ok)
50+ assert.ErrorIs(t, err, ports.ErrOutputNotFound)
51+}
52+
53+func TestModeChangeSwapsOnlyUntouchedDefault(t *testing.T) {
54+ m := newTestModel(t)
55+ m.mode = modeRecord
56+ m.modeChanged()
57+ assert.Equal(t, m.defaultRec, m.path)
58+ m.mode = modeScreenshot
59+ m.modeChanged()
60+ assert.Equal(t, m.defaultShot, m.path)
61+
62+ m.path = "mine.png"
63+ m.mode = modeRecord
64+ m.modeChanged()
65+ assert.Equal(t, "mine.png", m.path)
66+}
67+
68+func TestSubmitAccepts(t *testing.T) {
69+ m := newTestModel(t)
70+ ctx, cancel := context.WithCancel(context.Background())
71+ defer cancel()
72+ m.cancel = cancel
73+ m.path = filepath.Join(t.TempDir(), "shot.png")
74+ m.submit()
75+ assert.True(t, m.accepted)
76+ assert.Error(t, ctx.Err())
77+ assert.Equal(t, m.path, m.sel.Path)
78+ assert.Equal(t, ports.Screenshot, m.sel.Mode)
79+
80+ // Later dismissal cannot revoke an accepted selection.
81+ m.dismiss()
82+ assert.False(t, m.closed)
83+}
84+
85+func TestSubmitInvalidStaysOpen(t *testing.T) {
86+ m := newTestModel(t)
87+ ctx, cancel := context.WithCancel(context.Background())
88+ defer cancel()
89+ m.cancel = cancel
90+ m.path = ""
91+ m.submit()
92+ assert.False(t, m.accepted)
93+ assert.NoError(t, ctx.Err())
94+}
95+
96+func TestSubmitExistingPathStaysOpen(t *testing.T) {
97+ m := newTestModel(t)
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+ m.cancel = cancel
101+ m.path = filepath.Join(t.TempDir(), "taken.png")
102+ require.NoError(t, os.WriteFile(m.path, nil, 0o600))
103+ m.submit()
104+ assert.False(t, m.accepted)
105+ assert.NoError(t, ctx.Err())
106+ text, submittable, ok := m.status()
107+ assert.Equal(t, noticeExists, text)
108+ assert.True(t, submittable)
109+ assert.False(t, ok)
110+
111+ m.pathChanged()
112+ text, submittable, ok = m.status()
113+ assert.Equal(t, statusReady, text)
114+ assert.True(t, submittable)
115+ assert.True(t, ok)
116+}
117+
118+func TestStatusInvalid(t *testing.T) {
119+ m := newTestModel(t)
120+ m.region = "bad"
121+ text, submittable, ok := m.status()
122+ assert.Equal(t, errRegion.Error(), text)
123+ assert.False(t, submittable)
124+ assert.False(t, ok)
125+}
126+
127+func TestDismiss(t *testing.T) {
128+ m := newTestModel(t)
129+ ctx, cancel := context.WithCancel(context.Background())
130+ defer cancel()
131+ m.cancel = cancel
132+ m.dismiss()
133+ assert.True(t, m.closed)
134+ assert.Error(t, ctx.Err())
135+ m.submit()
136+ assert.False(t, m.accepted)
137+}
138+
139+func TestResult(t *testing.T) {
140+ closeErr := errors.New("close surface")
141+ canceledParent, cancelParent := context.WithCancel(context.Background())
142+ cancelParent()
143+ tests := []struct {
144+ name string
145+ parent context.Context
146+ accepted bool
147+ closed bool
148+ runErr error
149+ wantOK bool
150+ wantErr error
151+ wantNoErr bool
152+ wantNotErr error
153+ }{
154+ {name: "accepted canceled child", parent: context.Background(), accepted: true, runErr: context.Canceled, wantOK: true, wantNoErr: true},
155+ {name: "accepted nil run", parent: context.Background(), accepted: true, wantOK: true, wantNoErr: true},
156+ {name: "closed by button", parent: context.Background(), closed: true, runErr: context.Canceled, wantNoErr: true},
157+ {name: "closed by window", parent: context.Background(), wantNoErr: true},
158+ {name: "accepted but close failed", parent: context.Background(), accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr, wantNotErr: context.Canceled},
159+ {name: "accepted but run failed", parent: context.Background(), accepted: true, runErr: closeErr, wantErr: closeErr},
160+ {name: "closed with failure", parent: context.Background(), closed: true, runErr: fmt.Errorf("x: %w", closeErr), wantErr: closeErr},
161+ {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},
162+ {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},
163+ {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},
164+ {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled},
165+ {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
166+ {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
167+ }
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ m := newTestModel(t)
171+ m.accepted, m.closed = tt.accepted, tt.closed
172+ m.sel = ports.Selection{Mode: ports.Record, Path: "x.mp4"}
173+ sel, ok, err := m.result(tt.parent, tt.runErr)
174+ assert.Equal(t, tt.wantOK, ok)
175+ if tt.wantOK {
176+ assert.Equal(t, m.sel, sel)
177+ } else {
178+ assert.Equal(t, ports.Selection{}, sel)
179+ }
180+ if tt.wantNoErr {
181+ assert.NoError(t, err)
182+ }
183+ if tt.wantErr != nil {
184+ assert.ErrorIs(t, err, tt.wantErr)
185+ }
186+ if tt.wantNotErr != nil {
187+ assert.NotErrorIs(t, err, tt.wantNotErr)
188+ }
189+ })
190+ }
191+}
192+
193+// A parent deadline or cancel with only cancellation noise from Run reports
194+// the parent's own error bare, so callers can exit cleanly on it.
195+func TestResultReturnsBareParentError(t *testing.T) {
196+ m := newTestModel(t)
197+ parent, cancel := context.WithCancel(context.Background())
198+ cancel()
199+ _, _, err := m.result(parent, errors.Join(context.Canceled, context.Canceled))
200+ assert.True(t, err == context.Canceled, "bare canceled error")
201+
202+ deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
203+ defer stop()
204+ _, _, err = m.result(deadline, context.DeadlineExceeded)
205+ assert.True(t, err == context.DeadlineExceeded, "bare deadline error")
206+}
207+
208+func TestDropErrors(t *testing.T) {
209+ real := errors.New("real")
210+ assert.NoError(t, dropErrors(nil, context.Canceled))
211+ assert.NoError(t, dropErrors(context.Canceled, context.Canceled))
212+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled))
213+ assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled))
214+ wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
215+ assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours")
216+ both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled)
217+ assert.ErrorIs(t, both, real)
218+ assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
219+}
220+
221+func TestCheckPath(t *testing.T) {
222+ dir := t.TempDir()
223+ file := filepath.Join(dir, "taken.png")
224+ require.NoError(t, os.WriteFile(file, nil, 0o600))
225+ assert.Empty(t, checkPath(filepath.Join(dir, "new.png")))
226+ assert.Empty(t, checkPath(filepath.Join(dir, "new.mp4")), "extension does not dictate format")
227+ assert.Equal(t, noticeExists, checkPath(file))
228+ assert.Equal(t, noticeDir, checkPath(filepath.Join(dir, "missing", "new.png")))
229+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "new.png")), "parent is a file")
230+ assert.Empty(t, checkPath("relative-new-name.png"))
231+
232+ link := filepath.Join(dir, "dangling.png")
233+ require.NoError(t, os.Symlink(filepath.Join(dir, "nowhere"), link))
234+ assert.Equal(t, noticeExists, checkPath(link), "dangling symlink is not overwritten")
235+
236+ // A path component that is a regular file yields ENOTDIR, not ErrNotExist.
237+ assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "x", "y.png")))
238+}
239+
240+func TestRecordWithImageExtensionAccepted(t *testing.T) {
241+ m := newTestModel(t)
242+ m.mode = modeRecord
243+ m.path = filepath.Join(t.TempDir(), "clip.png")
244+ require.NoError(t, m.parse())
245+ assert.Equal(t, ports.Record, m.sel.Mode)
246+}
247+
248+func TestWriteStyleSheet(t *testing.T) {
249+ t.Setenv("TMPDIR", t.TempDir())
250+ path, remove, err := writeStyleSheet()
251+ require.NoError(t, err)
252+ data, err := os.ReadFile(path)
253+ require.NoError(t, err)
254+ assert.Equal(t, styleSheet, string(data))
255+ info, err := os.Stat(path)
256+ require.NoError(t, err)
257+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
258+ remove()
259+ _, err = os.Stat(path)
260+ assert.ErrorIs(t, err, os.ErrNotExist)
261+}
262+
263+func TestStyleSheetUsesMonospace(t *testing.T) {
264+ assert.Contains(t, styleSheet, "monospace")
265+}
266+
267+func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) {
268+ assert.Contains(t, styleSheet, "overflow-y: auto")
269+ assert.NotContains(t, styleSheet, "separator")
270+}
271+
272+func TestWriteStyleSheetFailure(t *testing.T) {
273+ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
274+ _, _, err := writeStyleSheet()
275+ assert.Error(t, err)
276+}
277+
278+func TestWindowHeightIsBounded(t *testing.T) {
279+ m := newTestModel(t)
280+ assert.Equal(t, 600+26*3, m.windowHeight())
281+ m.outputs = make([]outputChoice, 100)
282+ assert.Equal(t, 1000, m.windowHeight())
283+}
284+
285+func TestModeChangeNoop(t *testing.T) {
286+ m := newTestModel(t)
287+ m.notice = "keep"
288+ m.modeChanged()
289+ assert.Equal(t, "keep", m.notice)
290+ m.mode = modeRecord
291+ m.modeChanged()
292+ assert.Empty(t, m.notice)
293+}
A internal/adapters/gui/model.go +220 -0
@@ -0,0 +1,220 @@
1+package gui
2+
3+import (
4+ "context"
5+ "errors"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/nefergui"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+const (
19+ modeScreenshot = string(ports.Screenshot)
20+ modeRecord = string(ports.Record)
21+
22+ statusReady = "ready: press Capture (or Enter in a field)"
23+ noticeExists = "! file exists: choose another path"
24+
25+ // The panel is a regular toplevel: the compositor places it, and capture
26+ // support is limited to what the capture adapter offers.
27+ warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
28+ "of the current workspace. Off-screen sources and the cursor cannot be captured."
29+ recordNote = "duration 0 records until Ctrl+C in the terminal that started nefercap. " +
30+ "Video width and height must be even: set an even resolution if the source is odd."
31+
32+ noticeStat = "! cannot check the file path"
33+ noticeDir = "! folder does not exist"
34+ stampLayout = "20060102-150405"
35+
36+ // A finite default lets a desktop launch stop without a terminal; enter 0
37+ // to record until interrupted.
38+ defaultDuration = "10"
39+)
40+
41+// outputChoice is one precomputed radio entry.
42+type outputChoice struct {
43+ id uint32
44+ key, label string
45+ opts []nefergui.ButtonOption
46+}
47+
48+// model is owned by the single Run view; nothing else touches it while the
49+// window is open.
50+type model struct {
51+ outputs []outputChoice
52+ output string // key of the selected outputChoice
53+ mode string
54+ // lastMode detects mode changes to swap an untouched default path.
55+ lastMode string
56+
57+ path, region, fps, size, duration string
58+ defaultShot, defaultRec string
59+
60+ // notice is a submit-time problem shown until the path or mode changes.
61+ notice string
62+
63+ // Last duration parse, so an unchanged field is not re-parsed per frame.
64+ durSrc string
65+ durVal time.Duration
66+ durOK bool
67+ durCached bool
68+
69+ sel ports.Selection
70+ accepted bool
71+ closed bool
72+ cancel context.CancelFunc
73+}
74+
75+// newModel precomputes output labels, keys and default names once.
76+func newModel(outputs []ports.Output, now time.Time) (*model, error) {
77+ if len(outputs) == 0 {
78+ return nil, errNoOutputs
79+ }
80+ stamp := now.Format(stampLayout)
81+ m := &model{
82+ outputs: make([]outputChoice, len(outputs)),
83+ mode: modeScreenshot,
84+ lastMode: modeScreenshot,
85+ defaultShot: "nefercap-" + stamp + ".png",
86+ defaultRec: "nefercap-" + stamp + ".mp4",
87+ fps: "30",
88+ duration: defaultDuration,
89+ }
90+ m.path = m.defaultShot
91+ var b strings.Builder
92+ for i, o := range outputs {
93+ b.Reset()
94+ if o.Name != "" {
95+ b.WriteString(o.Name)
96+ } else {
97+ b.WriteString("output ")
98+ b.WriteString(strconv.FormatUint(uint64(o.ID), 10))
99+ }
100+ if o.Width > 0 && o.Height > 0 {
101+ b.WriteString(" ")
102+ b.WriteString(strconv.Itoa(o.Width))
103+ b.WriteByte('x')
104+ b.WriteString(strconv.Itoa(o.Height))
105+ }
106+ if o.Scale > 1 {
107+ b.WriteString(" scale ")
108+ b.WriteString(strconv.Itoa(o.Scale))
109+ }
110+ key := strconv.Itoa(i)
111+ m.outputs[i] = outputChoice{
112+ id: o.ID,
113+ key: key,
114+ label: b.String(),
115+ opts: []nefergui.ButtonOption{nefergui.Key("out-" + key)},
116+ }
117+ }
118+ m.output = m.outputs[0].key
119+ return m, nil
120+}
121+
122+// outputIndex returns the selected output's index, or -1.
123+func (m *model) outputIndex() int {
124+ for i := range m.outputs {
125+ if m.outputs[i].key == m.output {
126+ return i
127+ }
128+ }
129+ return -1
130+}
131+
132+// windowHeight sizes the initial window for the output list.
133+func (m *model) windowHeight() int {
134+ return min(600+26*len(m.outputs), 1000)
135+}
136+
137+// status reports the live validation result. submittable is false while the
138+// form is invalid; ok is false whenever the message is a problem, including a
139+// submit-time notice that still lets the user retry.
140+func (m *model) status() (text string, submittable, ok bool) {
141+ if err := m.parse(); err != nil {
142+ return err.Error(), false, false
143+ }
144+ if m.notice != "" {
145+ return m.notice, true, false
146+ }
147+ return statusReady, true, true
148+}
149+
150+// modeChanged swaps the default file name when the user has not edited it.
151+func (m *model) modeChanged() {
152+ if m.mode == m.lastMode {
153+ return
154+ }
155+ if m.path == m.defaultFor(m.lastMode) {
156+ m.path = m.defaultFor(m.mode)
157+ }
158+ m.lastMode = m.mode
159+ m.notice = ""
160+}
161+
162+func (m *model) defaultFor(mode string) string {
163+ if mode == modeRecord {
164+ return m.defaultRec
165+ }
166+ return m.defaultShot
167+}
168+
169+// pathChanged clears stale submit notices.
170+func (m *model) pathChanged() { m.notice = "" }
171+
172+// submit validates the form; on success it records the selection and closes
173+// the window by cancelling Run's child context. Repeated calls are ignored.
174+func (m *model) submit() {
175+ if m.accepted || m.closed {
176+ return
177+ }
178+ if err := m.parse(); err != nil {
179+ return
180+ }
181+ if n := checkPath(m.sel.Path); n != "" {
182+ m.notice = n
183+ return
184+ }
185+ m.notice = ""
186+ m.accepted = true
187+ m.stop()
188+}
189+
190+// checkPath returns a static notice when path cannot be created, or "". The
191+// folder must exist; write permission is left to the capture adapter.
192+func checkPath(path string) string {
193+ if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() {
194+ return noticeDir
195+ }
196+ _, err := os.Lstat(path)
197+ switch {
198+ case err == nil:
199+ return noticeExists
200+ case errors.Is(err, fs.ErrNotExist):
201+ return ""
202+ default:
203+ return noticeStat
204+ }
205+}
206+
207+// dismiss closes the window without a selection.
208+func (m *model) dismiss() {
209+ if m.accepted || m.closed {
210+ return
211+ }
212+ m.closed = true
213+ m.stop()
214+}
215+
216+func (m *model) stop() {
217+ if m.cancel != nil {
218+ m.cancel()
219+ }
220+}
@@ -0,0 +1,220 @@
1+package gui
2+
3+import (
4+ "context"
5+ "errors"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/nefergui"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+const (
19+ modeScreenshot = string(ports.Screenshot)
20+ modeRecord = string(ports.Record)
21+
22+ statusReady = "ready: press Capture (or Enter in a field)"
23+ noticeExists = "! file exists: choose another path"
24+
25+ // The panel is a regular toplevel: the compositor places it, and capture
26+ // support is limited to what the capture adapter offers.
27+ warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
28+ "of the current workspace. Off-screen sources and the cursor cannot be captured."
29+ recordNote = "duration 0 records until Ctrl+C in the terminal that started nefercap. " +
30+ "Video width and height must be even: set an even resolution if the source is odd."
31+
32+ noticeStat = "! cannot check the file path"
33+ noticeDir = "! folder does not exist"
34+ stampLayout = "20060102-150405"
35+
36+ // A finite default lets a desktop launch stop without a terminal; enter 0
37+ // to record until interrupted.
38+ defaultDuration = "10"
39+)
40+
41+// outputChoice is one precomputed radio entry.
42+type outputChoice struct {
43+ id uint32
44+ key, label string
45+ opts []nefergui.ButtonOption
46+}
47+
48+// model is owned by the single Run view; nothing else touches it while the
49+// window is open.
50+type model struct {
51+ outputs []outputChoice
52+ output string // key of the selected outputChoice
53+ mode string
54+ // lastMode detects mode changes to swap an untouched default path.
55+ lastMode string
56+
57+ path, region, fps, size, duration string
58+ defaultShot, defaultRec string
59+
60+ // notice is a submit-time problem shown until the path or mode changes.
61+ notice string
62+
63+ // Last duration parse, so an unchanged field is not re-parsed per frame.
64+ durSrc string
65+ durVal time.Duration
66+ durOK bool
67+ durCached bool
68+
69+ sel ports.Selection
70+ accepted bool
71+ closed bool
72+ cancel context.CancelFunc
73+}
74+
75+// newModel precomputes output labels, keys and default names once.
76+func newModel(outputs []ports.Output, now time.Time) (*model, error) {
77+ if len(outputs) == 0 {
78+ return nil, errNoOutputs
79+ }
80+ stamp := now.Format(stampLayout)
81+ m := &model{
82+ outputs: make([]outputChoice, len(outputs)),
83+ mode: modeScreenshot,
84+ lastMode: modeScreenshot,
85+ defaultShot: "nefercap-" + stamp + ".png",
86+ defaultRec: "nefercap-" + stamp + ".mp4",
87+ fps: "30",
88+ duration: defaultDuration,
89+ }
90+ m.path = m.defaultShot
91+ var b strings.Builder
92+ for i, o := range outputs {
93+ b.Reset()
94+ if o.Name != "" {
95+ b.WriteString(o.Name)
96+ } else {
97+ b.WriteString("output ")
98+ b.WriteString(strconv.FormatUint(uint64(o.ID), 10))
99+ }
100+ if o.Width > 0 && o.Height > 0 {
101+ b.WriteString(" ")
102+ b.WriteString(strconv.Itoa(o.Width))
103+ b.WriteByte('x')
104+ b.WriteString(strconv.Itoa(o.Height))
105+ }
106+ if o.Scale > 1 {
107+ b.WriteString(" scale ")
108+ b.WriteString(strconv.Itoa(o.Scale))
109+ }
110+ key := strconv.Itoa(i)
111+ m.outputs[i] = outputChoice{
112+ id: o.ID,
113+ key: key,
114+ label: b.String(),
115+ opts: []nefergui.ButtonOption{nefergui.Key("out-" + key)},
116+ }
117+ }
118+ m.output = m.outputs[0].key
119+ return m, nil
120+}
121+
122+// outputIndex returns the selected output's index, or -1.
123+func (m *model) outputIndex() int {
124+ for i := range m.outputs {
125+ if m.outputs[i].key == m.output {
126+ return i
127+ }
128+ }
129+ return -1
130+}
131+
132+// windowHeight sizes the initial window for the output list.
133+func (m *model) windowHeight() int {
134+ return min(600+26*len(m.outputs), 1000)
135+}
136+
137+// status reports the live validation result. submittable is false while the
138+// form is invalid; ok is false whenever the message is a problem, including a
139+// submit-time notice that still lets the user retry.
140+func (m *model) status() (text string, submittable, ok bool) {
141+ if err := m.parse(); err != nil {
142+ return err.Error(), false, false
143+ }
144+ if m.notice != "" {
145+ return m.notice, true, false
146+ }
147+ return statusReady, true, true
148+}
149+
150+// modeChanged swaps the default file name when the user has not edited it.
151+func (m *model) modeChanged() {
152+ if m.mode == m.lastMode {
153+ return
154+ }
155+ if m.path == m.defaultFor(m.lastMode) {
156+ m.path = m.defaultFor(m.mode)
157+ }
158+ m.lastMode = m.mode
159+ m.notice = ""
160+}
161+
162+func (m *model) defaultFor(mode string) string {
163+ if mode == modeRecord {
164+ return m.defaultRec
165+ }
166+ return m.defaultShot
167+}
168+
169+// pathChanged clears stale submit notices.
170+func (m *model) pathChanged() { m.notice = "" }
171+
172+// submit validates the form; on success it records the selection and closes
173+// the window by cancelling Run's child context. Repeated calls are ignored.
174+func (m *model) submit() {
175+ if m.accepted || m.closed {
176+ return
177+ }
178+ if err := m.parse(); err != nil {
179+ return
180+ }
181+ if n := checkPath(m.sel.Path); n != "" {
182+ m.notice = n
183+ return
184+ }
185+ m.notice = ""
186+ m.accepted = true
187+ m.stop()
188+}
189+
190+// checkPath returns a static notice when path cannot be created, or "". The
191+// folder must exist; write permission is left to the capture adapter.
192+func checkPath(path string) string {
193+ if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() {
194+ return noticeDir
195+ }
196+ _, err := os.Lstat(path)
197+ switch {
198+ case err == nil:
199+ return noticeExists
200+ case errors.Is(err, fs.ErrNotExist):
201+ return ""
202+ default:
203+ return noticeStat
204+ }
205+}
206+
207+// dismiss closes the window without a selection.
208+func (m *model) dismiss() {
209+ if m.accepted || m.closed {
210+ return
211+ }
212+ m.closed = true
213+ m.stop()
214+}
215+
216+func (m *model) stop() {
217+ if m.cancel != nil {
218+ m.cancel()
219+ }
220+}
A internal/adapters/gui/smoke_test.go +31 -0
@@ -0,0 +1,31 @@
1+package gui
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefergui"
10+)
11+
12+// TestSmokeRunFrames opens the real panel for a few frames. It needs a Wayland
13+// session with Vulkan, so it runs only when NEFERCAP_GUI_SMOKE=1.
14+func TestSmokeRunFrames(t *testing.T) {
15+ if os.Getenv("NEFERCAP_GUI_SMOKE") != "1" {
16+ t.Skip("set NEFERCAP_GUI_SMOKE=1 on a Wayland session to open the panel")
17+ }
18+ m := newTestModel(t)
19+ m.mode = modeRecord
20+ sheet, remove, err := writeStyleSheet()
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer remove()
25+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26+ defer cancel()
27+ if err := nefergui.RunFrames(ctx, 3, m, view, nil,
28+ nefergui.Title("NeferCap smoke"), nefergui.Size(640, m.windowHeight()), nefergui.Styles(sheet)); err != nil {
29+ t.Fatal(err)
30+ }
31+}
@@ -0,0 +1,31 @@
1+package gui
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefergui"
10+)
11+
12+// TestSmokeRunFrames opens the real panel for a few frames. It needs a Wayland
13+// session with Vulkan, so it runs only when NEFERCAP_GUI_SMOKE=1.
14+func TestSmokeRunFrames(t *testing.T) {
15+ if os.Getenv("NEFERCAP_GUI_SMOKE") != "1" {
16+ t.Skip("set NEFERCAP_GUI_SMOKE=1 on a Wayland session to open the panel")
17+ }
18+ m := newTestModel(t)
19+ m.mode = modeRecord
20+ sheet, remove, err := writeStyleSheet()
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer remove()
25+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26+ defer cancel()
27+ if err := nefergui.RunFrames(ctx, 3, m, view, nil,
28+ nefergui.Title("NeferCap smoke"), nefergui.Size(640, m.windowHeight()), nefergui.Styles(sheet)); err != nil {
29+ t.Fatal(err)
30+ }
31+}
A internal/adapters/gui/style.css +39 -0
@@ -0,0 +1,39 @@
1+/* Dark, monospace, thin borders, one accent (#4fd1a5). */
2+app {
3+ display: flex; flex-direction: column; width: 100%; height: 100%;
4+ box-sizing: border-box; padding: 16px 20px; row-gap: 6px;
5+ background: #0d1014; color: #c9d1d9;
6+ font-family: monospace; font-size: 14px; line-height: 1.4;
7+ accent-color: #4fd1a5;
8+ /* Short windows scroll with the wheel; NeferGUI 0.1.0 does not scroll focus into view. */
9+ overflow-y: auto;
10+}
11+/* Children keep their natural height so overflow scrolls instead of squashing. */
12+heading, text, radio, row, column, button { flex-shrink: 0; }
13+heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
14+text.section { margin-top: 8px; color: #8b949e; }
15+text.note { color: #8b949e; font-size: 12px; }
16+text.status { margin-top: 8px; color: #8b949e; }
17+text.status.bad { color: #e6edf3; font-weight: 700; }
18+text.field-label { width: 96px; flex-shrink: 0; color: #8b949e; }
19+column.group {
20+ display: flex; flex-direction: column; row-gap: 2px;
21+ box-sizing: border-box; padding: 6px 8px; border: 1px solid #2a313b;
22+}
23+row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
24+row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
25+radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
26+radio:hover { color: #e6edf3; }
27+radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
28+input {
29+ box-sizing: border-box; flex-grow: 1; min-width: 0; padding: 4px 8px;
30+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
31+}
32+input:focus { border-color: #4fd1a5; }
33+button {
34+ box-sizing: border-box; padding: 6px 16px;
35+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
36+}
37+button:hover { border-color: #4fd1a5; }
38+button.primary { background: #4fd1a5; color: #0d1014; border-color: #4fd1a5; font-weight: 700; }
39+button:disabled, button.primary:disabled { background: #12161c; color: #5b6570; border-color: #2a313b; }
@@ -0,0 +1,39 @@
1+/* Dark, monospace, thin borders, one accent (#4fd1a5). */
2+app {
3+ display: flex; flex-direction: column; width: 100%; height: 100%;
4+ box-sizing: border-box; padding: 16px 20px; row-gap: 6px;
5+ background: #0d1014; color: #c9d1d9;
6+ font-family: monospace; font-size: 14px; line-height: 1.4;
7+ accent-color: #4fd1a5;
8+ /* Short windows scroll with the wheel; NeferGUI 0.1.0 does not scroll focus into view. */
9+ overflow-y: auto;
10+}
11+/* Children keep their natural height so overflow scrolls instead of squashing. */
12+heading, text, radio, row, column, button { flex-shrink: 0; }
13+heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
14+text.section { margin-top: 8px; color: #8b949e; }
15+text.note { color: #8b949e; font-size: 12px; }
16+text.status { margin-top: 8px; color: #8b949e; }
17+text.status.bad { color: #e6edf3; font-weight: 700; }
18+text.field-label { width: 96px; flex-shrink: 0; color: #8b949e; }
19+column.group {
20+ display: flex; flex-direction: column; row-gap: 2px;
21+ box-sizing: border-box; padding: 6px 8px; border: 1px solid #2a313b;
22+}
23+row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
24+row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
25+radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
26+radio:hover { color: #e6edf3; }
27+radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
28+input {
29+ box-sizing: border-box; flex-grow: 1; min-width: 0; padding: 4px 8px;
30+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
31+}
32+input:focus { border-color: #4fd1a5; }
33+button {
34+ box-sizing: border-box; padding: 6px 16px;
35+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
36+}
37+button:hover { border-color: #4fd1a5; }
38+button.primary { background: #4fd1a5; color: #0d1014; border-color: #4fd1a5; font-weight: 700; }
39+button:disabled, button.primary:disabled { background: #12161c; color: #5b6570; border-color: #2a313b; }
A internal/adapters/gui/view.go +137 -0
@@ -0,0 +1,137 @@
1+package gui
2+
3+import "github.com/bnema/nefergui"
4+
5+// Option slices are built once at init; the view only passes them through.
6+var (
7+ rootOpts = []nefergui.ContainerOption{nefergui.Key("root")}
8+ titleOpts = []nefergui.HeadingOption{nefergui.Key("title"), nefergui.Level(1)}
9+ outputTitle = sectionOpts("output-title")
10+ modeTitle = sectionOpts("mode-title")
11+ targetTitle = sectionOpts("target-title")
12+ outputsGroup = groupOpts("outputs")
13+ modeGroup = groupOpts("modes")
14+ targetGroup = groupOpts("target")
15+ recordGroup = groupOpts("record")
16+ shotOpts = []nefergui.ButtonOption{nefergui.Key("mode-screenshot")}
17+ recOpts = []nefergui.ButtonOption{nefergui.Key("mode-record")}
18+ recordNoteOp = noteOpts("record-note")
19+ warningOpts = noteOpts("warning")
20+ statusGood = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status")}
21+ statusBad = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status"), nefergui.Class("bad")}
22+ actionsOpts = []nefergui.ContainerOption{nefergui.Key("actions"), nefergui.Class("actions")}
23+ captureOn = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary")}
24+ captureOff = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary"), nefergui.Disabled(true)}
25+ closeOpts = []nefergui.ButtonOption{nefergui.Key("close")}
26+
27+ fileField = newField("file", "file", "path of the new capture file")
28+ regionField = newField("region", "region", "X,Y,WxH (empty = full output)")
29+ fpsField = newField("fps", "fps", fpsPlaceholder)
30+ sizeField = newField("size", "resolution", "WxH (empty = source size)")
31+ durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")
32+)
33+
34+func sectionOpts(key string) []nefergui.ContainerOption {
35+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("section")}
36+}
37+
38+func groupOpts(key string) []nefergui.ContainerOption {
39+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("group")}
40+}
41+
42+func noteOpts(key string) []nefergui.ContainerOption {
43+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("note")}
44+}
45+
46+// field is a labelled text input row with precomputed options.
47+type field struct {
48+ label, key string
49+ row, name []nefergui.ContainerOption
50+ input []nefergui.EditOption
51+}
52+
53+func newField(key, label, placeholder string) *field {
54+ return &field{
55+ label: label,
56+ key: key,
57+ row: []nefergui.ContainerOption{nefergui.Key(key + "-row"), nefergui.Class("field")},
58+ name: []nefergui.ContainerOption{nefergui.Key(key + "-label"), nefergui.Class("field-label")},
59+ input: []nefergui.EditOption{nefergui.Key(key), nefergui.Placeholder(placeholder)},
60+ }
61+}
62+
63+// show declares one row and reports edit and Enter events.
64+func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bool) {
65+ row := parent.Row(f.row...)
66+ row.Text(f.label, f.name...)
67+ ev := row.Input(f.label, value, f.input...)
68+ return ev.Changed(), ev.Submitted()
69+}
70+
71+// view builds the whole panel from the model. The model is owned by this view
72+// alone; events mutate it while the frame is built.
73+func view(f *nefergui.Frame, m *model) {
74+ root := f.Root(rootOpts...)
75+ submit := false
76+ root.Heading("NeferCap", titleOpts...)
77+
78+ text, submittable, ok := m.status()
79+ if ok {
80+ root.Text(text, statusGood...)
81+ } else {
82+ root.Text(text, statusBad...)
83+ }
84+
85+ actions := root.Row(actionsOpts...)
86+ capture := captureOff
87+ if submittable {
88+ capture = captureOn
89+ }
90+ if actions.Button("Capture", capture...).Activated() && submittable {
91+ submit = true
92+ }
93+ if actions.Button("Close", closeOpts...).Activated() {
94+ m.dismiss()
95+ }
96+
97+ root.Text("output", outputTitle...)
98+ outs := root.Column(outputsGroup...)
99+ for i := range m.outputs {
100+ o := &m.outputs[i]
101+ outs.Radio(o.label, o.key, &m.output, o.opts...)
102+ }
103+
104+ root.Text("capture", modeTitle...)
105+ modes := root.Column(modeGroup...)
106+ modes.Radio("screenshot", modeScreenshot, &m.mode, shotOpts...)
107+ modes.Radio("record", modeRecord, &m.mode, recOpts...)
108+ m.modeChanged()
109+
110+ root.Text("target", targetTitle...)
111+ target := root.Column(targetGroup...)
112+ changed, entered := fileField.show(target, &m.path)
113+ if changed {
114+ m.pathChanged()
115+ }
116+ submit = submit || entered
117+ _, entered = regionField.show(target, &m.region)
118+ submit = submit || entered
119+
120+ if m.mode == modeRecord {
121+ rec := root.Column(recordGroup...)
122+ for _, e := range [...]struct {
123+ f *field
124+ v *string
125+ }{{fpsField, &m.fps}, {sizeField, &m.size}, {durationField, &m.duration}} {
126+ _, entered = e.f.show(rec, e.v)
127+ submit = submit || entered
128+ }
129+ rec.Text(recordNote, recordNoteOp...)
130+ }
131+
132+ root.Text(warning, warningOpts...)
133+
134+ if submit {
135+ m.submit()
136+ }
137+}
@@ -0,0 +1,137 @@
1+package gui
2+
3+import "github.com/bnema/nefergui"
4+
5+// Option slices are built once at init; the view only passes them through.
6+var (
7+ rootOpts = []nefergui.ContainerOption{nefergui.Key("root")}
8+ titleOpts = []nefergui.HeadingOption{nefergui.Key("title"), nefergui.Level(1)}
9+ outputTitle = sectionOpts("output-title")
10+ modeTitle = sectionOpts("mode-title")
11+ targetTitle = sectionOpts("target-title")
12+ outputsGroup = groupOpts("outputs")
13+ modeGroup = groupOpts("modes")
14+ targetGroup = groupOpts("target")
15+ recordGroup = groupOpts("record")
16+ shotOpts = []nefergui.ButtonOption{nefergui.Key("mode-screenshot")}
17+ recOpts = []nefergui.ButtonOption{nefergui.Key("mode-record")}
18+ recordNoteOp = noteOpts("record-note")
19+ warningOpts = noteOpts("warning")
20+ statusGood = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status")}
21+ statusBad = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status"), nefergui.Class("bad")}
22+ actionsOpts = []nefergui.ContainerOption{nefergui.Key("actions"), nefergui.Class("actions")}
23+ captureOn = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary")}
24+ captureOff = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary"), nefergui.Disabled(true)}
25+ closeOpts = []nefergui.ButtonOption{nefergui.Key("close")}
26+
27+ fileField = newField("file", "file", "path of the new capture file")
28+ regionField = newField("region", "region", "X,Y,WxH (empty = full output)")
29+ fpsField = newField("fps", "fps", fpsPlaceholder)
30+ sizeField = newField("size", "resolution", "WxH (empty = source size)")
31+ durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")
32+)
33+
34+func sectionOpts(key string) []nefergui.ContainerOption {
35+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("section")}
36+}
37+
38+func groupOpts(key string) []nefergui.ContainerOption {
39+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("group")}
40+}
41+
42+func noteOpts(key string) []nefergui.ContainerOption {
43+ return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("note")}
44+}
45+
46+// field is a labelled text input row with precomputed options.
47+type field struct {
48+ label, key string
49+ row, name []nefergui.ContainerOption
50+ input []nefergui.EditOption
51+}
52+
53+func newField(key, label, placeholder string) *field {
54+ return &field{
55+ label: label,
56+ key: key,
57+ row: []nefergui.ContainerOption{nefergui.Key(key + "-row"), nefergui.Class("field")},
58+ name: []nefergui.ContainerOption{nefergui.Key(key + "-label"), nefergui.Class("field-label")},
59+ input: []nefergui.EditOption{nefergui.Key(key), nefergui.Placeholder(placeholder)},
60+ }
61+}
62+
63+// show declares one row and reports edit and Enter events.
64+func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bool) {
65+ row := parent.Row(f.row...)
66+ row.Text(f.label, f.name...)
67+ ev := row.Input(f.label, value, f.input...)
68+ return ev.Changed(), ev.Submitted()
69+}
70+
71+// view builds the whole panel from the model. The model is owned by this view
72+// alone; events mutate it while the frame is built.
73+func view(f *nefergui.Frame, m *model) {
74+ root := f.Root(rootOpts...)
75+ submit := false
76+ root.Heading("NeferCap", titleOpts...)
77+
78+ text, submittable, ok := m.status()
79+ if ok {
80+ root.Text(text, statusGood...)
81+ } else {
82+ root.Text(text, statusBad...)
83+ }
84+
85+ actions := root.Row(actionsOpts...)
86+ capture := captureOff
87+ if submittable {
88+ capture = captureOn
89+ }
90+ if actions.Button("Capture", capture...).Activated() && submittable {
91+ submit = true
92+ }
93+ if actions.Button("Close", closeOpts...).Activated() {
94+ m.dismiss()
95+ }
96+
97+ root.Text("output", outputTitle...)
98+ outs := root.Column(outputsGroup...)
99+ for i := range m.outputs {
100+ o := &m.outputs[i]
101+ outs.Radio(o.label, o.key, &m.output, o.opts...)
102+ }
103+
104+ root.Text("capture", modeTitle...)
105+ modes := root.Column(modeGroup...)
106+ modes.Radio("screenshot", modeScreenshot, &m.mode, shotOpts...)
107+ modes.Radio("record", modeRecord, &m.mode, recOpts...)
108+ m.modeChanged()
109+
110+ root.Text("target", targetTitle...)
111+ target := root.Column(targetGroup...)
112+ changed, entered := fileField.show(target, &m.path)
113+ if changed {
114+ m.pathChanged()
115+ }
116+ submit = submit || entered
117+ _, entered = regionField.show(target, &m.region)
118+ submit = submit || entered
119+
120+ if m.mode == modeRecord {
121+ rec := root.Column(recordGroup...)
122+ for _, e := range [...]struct {
123+ f *field
124+ v *string
125+ }{{fpsField, &m.fps}, {sizeField, &m.size}, {durationField, &m.duration}} {
126+ _, entered = e.f.show(rec, e.v)
127+ submit = submit || entered
128+ }
129+ rec.Text(recordNote, recordNoteOp...)
130+ }
131+
132+ root.Text(warning, warningOpts...)
133+
134+ if submit {
135+ m.submit()
136+ }
137+}

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