detached b11e89c

feat(gui): add synchronous NeferGUI control panel selector

commit
b11e89c7fc1ab1a0465f4f4dcfbfccde3dd00c45
author
brice · · 2026-09-30 08:11 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
3cdbdaa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1084 -0
A internal/adapters/gui/form.go +149 -0
@@ -0,0 +1,149 @@
1+package gui
2+
3+import (
4+ "errors"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/nefercap/internal/ports"
9+)
10+
11+// Form limits. Bounds are generous but keep every value far from int overflow.
12+const (
13+ maxDim = 16384
14+ maxFPS = 240
15+ maxDuration = 24 * time.Hour
16+)
17+
18+// Validation messages are static so status rendering never allocates.
19+var (
20+ errOutput = errors.New("! select an output")
21+ errMode = errors.New("! select screenshot or record")
22+ errPath = errors.New("! file must name a path (not empty, not a directory)")
23+ errRegion = errors.New("! region must be X,Y,WxH with X,Y >= 0 and W,H > 0, or empty")
24+ errFPS = errors.New("! fps must be a whole number from 1 to 240")
25+ errSize = errors.New("! size must be WxH with both > 0, or empty for source size")
26+ errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
27+)
28+
29+// parse validates the form and, on success, stores the result in m.sel. It
30+// reuses m's storage and does not allocate for valid or invalid input.
31+func (m *model) parse() error {
32+ oi := m.outputIndex()
33+ if oi < 0 {
34+ return errOutput
35+ }
36+ path := strings.TrimSpace(m.path)
37+ if path == "" || strings.HasSuffix(path, "/") {
38+ return errPath
39+ }
40+ region, ok := parseRegion(m.region)
41+ if !ok {
42+ return errRegion
43+ }
44+ sel := ports.Selection{
45+ Target: ports.Target{OutputID: m.outputs[oi].id, Region: region},
46+ Path: path,
47+ }
48+ switch m.mode {
49+ case modeScreenshot:
50+ sel.Mode = ports.Screenshot
51+ case modeRecord:
52+ sel.Mode = ports.Record
53+ fps, ok := parseUint(m.fps, maxFPS)
54+ if !ok || fps < 1 {
55+ return errFPS
56+ }
57+ w, h, ok := parseSize(m.size)
58+ if !ok {
59+ return errSize
60+ }
61+ dur, ok := parseDuration(m.duration)
62+ if !ok {
63+ return errDuration
64+ }
65+ sel.Video = ports.VideoSettings{FPS: fps, Width: w, Height: h}
66+ sel.Duration = dur
67+ default:
68+ return errMode
69+ }
70+ m.sel = sel
71+ return nil
72+}
73+
74+// parseUint parses plain decimal digits no greater than limit.
75+func parseUint(s string, limit int) (int, bool) {
76+ s = strings.TrimSpace(s)
77+ if s == "" {
78+ return 0, false
79+ }
80+ n := 0
81+ for i := 0; i < len(s); i++ {
82+ c := s[i]
83+ if c < '0' || c > '9' {
84+ return 0, false
85+ }
86+ n = n*10 + int(c-'0')
87+ if n > limit {
88+ return 0, false
89+ }
90+ }
91+ return n, true
92+}
93+
94+// parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
95+func parseRegion(s string) (ports.Region, bool) {
96+ s = strings.TrimSpace(s)
97+ if s == "" {
98+ return ports.Region{}, true
99+ }
100+ xs, rest, ok := strings.Cut(s, ",")
101+ if !ok {
102+ return ports.Region{}, false
103+ }
104+ ys, size, ok := strings.Cut(rest, ",")
105+ if !ok {
106+ return ports.Region{}, false
107+ }
108+ x, ok1 := parseUint(xs, maxDim)
109+ y, ok2 := parseUint(ys, maxDim)
110+ w, h, ok3 := parseSize(size)
111+ if !ok1 || !ok2 || !ok3 || w == 0 {
112+ return ports.Region{}, false
113+ }
114+ return ports.Region{X: x, Y: y, Width: w, Height: h}, true
115+}
116+
117+// parseSize parses "WxH" with both positive, or empty as 0x0.
118+func parseSize(s string) (w, h int, ok bool) {
119+ s = strings.TrimSpace(s)
120+ if s == "" {
121+ return 0, 0, true
122+ }
123+ i := strings.IndexAny(s, "xX")
124+ if i < 0 {
125+ return 0, 0, false
126+ }
127+ w, ok1 := parseUint(s[:i], maxDim)
128+ h, ok2 := parseUint(s[i+1:], maxDim)
129+ if !ok1 || !ok2 || w < 1 || h < 1 {
130+ return 0, 0, false
131+ }
132+ return w, h, true
133+}
134+
135+// parseDuration accepts whole seconds or a Go duration; empty means zero.
136+func parseDuration(s string) (time.Duration, bool) {
137+ s = strings.TrimSpace(s)
138+ if s == "" {
139+ return 0, true
140+ }
141+ if n, ok := parseUint(s, int(maxDuration/time.Second)); ok {
142+ return time.Duration(n) * time.Second, true
143+ }
144+ d, err := time.ParseDuration(s)
145+ if err != nil || d < 0 || d > maxDuration {
146+ return 0, false
147+ }
148+ return d, true
149+}
@@ -0,0 +1,149 @@
1+package gui
2+
3+import (
4+ "errors"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/nefercap/internal/ports"
9+)
10+
11+// Form limits. Bounds are generous but keep every value far from int overflow.
12+const (
13+ maxDim = 16384
14+ maxFPS = 240
15+ maxDuration = 24 * time.Hour
16+)
17+
18+// Validation messages are static so status rendering never allocates.
19+var (
20+ errOutput = errors.New("! select an output")
21+ errMode = errors.New("! select screenshot or record")
22+ errPath = errors.New("! file must name a path (not empty, not a directory)")
23+ errRegion = errors.New("! region must be X,Y,WxH with X,Y >= 0 and W,H > 0, or empty")
24+ errFPS = errors.New("! fps must be a whole number from 1 to 240")
25+ errSize = errors.New("! size must be WxH with both > 0, or empty for source size")
26+ errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
27+)
28+
29+// parse validates the form and, on success, stores the result in m.sel. It
30+// reuses m's storage and does not allocate for valid or invalid input.
31+func (m *model) parse() error {
32+ oi := m.outputIndex()
33+ if oi < 0 {
34+ return errOutput
35+ }
36+ path := strings.TrimSpace(m.path)
37+ if path == "" || strings.HasSuffix(path, "/") {
38+ return errPath
39+ }
40+ region, ok := parseRegion(m.region)
41+ if !ok {
42+ return errRegion
43+ }
44+ sel := ports.Selection{
45+ Target: ports.Target{OutputID: m.outputs[oi].id, Region: region},
46+ Path: path,
47+ }
48+ switch m.mode {
49+ case modeScreenshot:
50+ sel.Mode = ports.Screenshot
51+ case modeRecord:
52+ sel.Mode = ports.Record
53+ fps, ok := parseUint(m.fps, maxFPS)
54+ if !ok || fps < 1 {
55+ return errFPS
56+ }
57+ w, h, ok := parseSize(m.size)
58+ if !ok {
59+ return errSize
60+ }
61+ dur, ok := parseDuration(m.duration)
62+ if !ok {
63+ return errDuration
64+ }
65+ sel.Video = ports.VideoSettings{FPS: fps, Width: w, Height: h}
66+ sel.Duration = dur
67+ default:
68+ return errMode
69+ }
70+ m.sel = sel
71+ return nil
72+}
73+
74+// parseUint parses plain decimal digits no greater than limit.
75+func parseUint(s string, limit int) (int, bool) {
76+ s = strings.TrimSpace(s)
77+ if s == "" {
78+ return 0, false
79+ }
80+ n := 0
81+ for i := 0; i < len(s); i++ {
82+ c := s[i]
83+ if c < '0' || c > '9' {
84+ return 0, false
85+ }
86+ n = n*10 + int(c-'0')
87+ if n > limit {
88+ return 0, false
89+ }
90+ }
91+ return n, true
92+}
93+
94+// parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
95+func parseRegion(s string) (ports.Region, bool) {
96+ s = strings.TrimSpace(s)
97+ if s == "" {
98+ return ports.Region{}, true
99+ }
100+ xs, rest, ok := strings.Cut(s, ",")
101+ if !ok {
102+ return ports.Region{}, false
103+ }
104+ ys, size, ok := strings.Cut(rest, ",")
105+ if !ok {
106+ return ports.Region{}, false
107+ }
108+ x, ok1 := parseUint(xs, maxDim)
109+ y, ok2 := parseUint(ys, maxDim)
110+ w, h, ok3 := parseSize(size)
111+ if !ok1 || !ok2 || !ok3 || w == 0 {
112+ return ports.Region{}, false
113+ }
114+ return ports.Region{X: x, Y: y, Width: w, Height: h}, true
115+}
116+
117+// parseSize parses "WxH" with both positive, or empty as 0x0.
118+func parseSize(s string) (w, h int, ok bool) {
119+ s = strings.TrimSpace(s)
120+ if s == "" {
121+ return 0, 0, true
122+ }
123+ i := strings.IndexAny(s, "xX")
124+ if i < 0 {
125+ return 0, 0, false
126+ }
127+ w, ok1 := parseUint(s[:i], maxDim)
128+ h, ok2 := parseUint(s[i+1:], maxDim)
129+ if !ok1 || !ok2 || w < 1 || h < 1 {
130+ return 0, 0, false
131+ }
132+ return w, h, true
133+}
134+
135+// parseDuration accepts whole seconds or a Go duration; empty means zero.
136+func parseDuration(s string) (time.Duration, bool) {
137+ s = strings.TrimSpace(s)
138+ if s == "" {
139+ return 0, true
140+ }
141+ if n, ok := parseUint(s, int(maxDuration/time.Second)); ok {
142+ return time.Duration(n) * time.Second, true
143+ }
144+ d, err := time.ParseDuration(s)
145+ if err != nil || d < 0 || d > maxDuration {
146+ return 0, false
147+ }
148+ return d, true
149+}
A internal/adapters/gui/form_test.go +175 -0
@@ -0,0 +1,175 @@
1+package gui
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+var testOutputs = []ports.Output{
14+ {ID: 7, Name: "DP-1", Width: 2560, Height: 1440, Scale: 1},
15+ {ID: 9, Name: "", Width: 1920, Height: 1080, Scale: 2},
16+ {ID: 11},
17+}
18+
19+func newTestModel(t testing.TB) *model {
20+ t.Helper()
21+ m, err := newModel(testOutputs, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
22+ require.NoError(t, err)
23+ return m
24+}
25+
26+func TestParseScreenshotDefaults(t *testing.T) {
27+ m := newTestModel(t)
28+ require.NoError(t, m.parse())
29+ assert.Equal(t, ports.Selection{
30+ Mode: ports.Screenshot,
31+ Target: ports.Target{OutputID: 7},
32+ Path: "nefercap-20260102-030405.png",
33+ }, m.sel)
34+}
35+
36+func TestParseRecordDefaults(t *testing.T) {
37+ m := newTestModel(t)
38+ m.mode = modeRecord
39+ require.NoError(t, m.parse())
40+ assert.Equal(t, ports.Selection{
41+ Mode: ports.Record,
42+ Target: ports.Target{OutputID: 7},
43+ Path: "nefercap-20260102-030405.png",
44+ Video: ports.VideoSettings{FPS: 30},
45+ }, m.sel)
46+}
47+
48+func TestParseRecordFull(t *testing.T) {
49+ m := newTestModel(t)
50+ m.mode = modeRecord
51+ m.output = m.outputs[1].key
52+ m.path = " out.mp4 "
53+ m.region = " 10, 20, 640x480 "
54+ m.fps = "60"
55+ m.size = "1280X720"
56+ m.duration = "1m30s"
57+ require.NoError(t, m.parse())
58+ assert.Equal(t, ports.Selection{
59+ Mode: ports.Record,
60+ Target: ports.Target{OutputID: 9, Region: ports.Region{X: 10, Y: 20, Width: 640, Height: 480}},
61+ Path: "out.mp4",
62+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720},
63+ Duration: 90 * time.Second,
64+ }, m.sel)
65+}
66+
67+func TestParseInvalid(t *testing.T) {
68+ tests := []struct {
69+ name string
70+ edit func(*model)
71+ want error
72+ }{
73+ {"unknown output", func(m *model) { m.output = "nope" }, errOutput},
74+ {"empty path", func(m *model) { m.path = " " }, errPath},
75+ {"directory path", func(m *model) { m.path = "dir/" }, errPath},
76+ {"bad mode", func(m *model) { m.mode = "" }, errMode},
77+ {"region fields", func(m *model) { m.region = "1,2" }, errRegion},
78+ {"region no comma", func(m *model) { m.region = "1x2" }, errRegion},
79+ {"region zero width", func(m *model) { m.region = "0,0,0x5" }, errRegion},
80+ {"region zero height", func(m *model) { m.region = "0,0,5x0" }, errRegion},
81+ {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
82+ {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
83+ {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
84+ {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
85+ {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
86+ {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
87+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "241" }, errFPS},
88+ {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
89+ {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
90+ {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
91+ {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
92+ {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
93+ {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
94+ {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
95+ {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
96+ {"duration huge seconds", func(m *model) { m.mode = modeRecord; m.duration = "999999999999999999999" }, errDuration},
97+ }
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ m := newTestModel(t)
101+ tt.edit(m)
102+ assert.ErrorIs(t, m.parse(), tt.want)
103+ })
104+ }
105+}
106+
107+func TestScreenshotIgnoresRecordFields(t *testing.T) {
108+ m := newTestModel(t)
109+ m.fps, m.size, m.duration = "bad", "bad", "bad"
110+ require.NoError(t, m.parse())
111+ assert.Equal(t, ports.VideoSettings{}, m.sel.Video)
112+ assert.Zero(t, m.sel.Duration)
113+}
114+
115+func TestInvalidParseKeepsPreviousSelection(t *testing.T) {
116+ m := newTestModel(t)
117+ require.NoError(t, m.parse())
118+ want := m.sel
119+ m.path = ""
120+ require.Error(t, m.parse())
121+ assert.Equal(t, want, m.sel)
122+}
123+
124+func TestParseDuration(t *testing.T) {
125+ for in, want := range map[string]time.Duration{
126+ "": 0, "0": 0, "5": 5 * time.Second, " 90 ": 90 * time.Second,
127+ "1.5s": 1500 * time.Millisecond, "2m": 2 * time.Minute, "24h": 24 * time.Hour, "86400": 24 * time.Hour,
128+ } {
129+ got, ok := parseDuration(in)
130+ assert.True(t, ok, in)
131+ assert.Equal(t, want, got, in)
132+ }
133+}
134+
135+func TestParseUintBounds(t *testing.T) {
136+ n, ok := parseUint("16384", maxDim)
137+ assert.True(t, ok)
138+ assert.Equal(t, 16384, n)
139+ _, ok = parseUint("16385", maxDim)
140+ assert.False(t, ok)
141+ _, ok = parseUint("", maxDim)
142+ assert.False(t, ok)
143+}
144+
145+// The status line runs every frame and must not allocate for valid or invalid
146+// forms: parsing uses only the standard library on the model's own strings.
147+func TestFormAllocations(t *testing.T) {
148+ m := newTestModel(t)
149+ m.mode = modeRecord
150+ m.region = "10,20,640x480"
151+ m.size = "1280x720"
152+ m.duration = "1m30s"
153+ require.NoError(t, m.parse())
154+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
155+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
156+ m.fps = "abc"
157+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
158+ m.fps = "30"
159+ m.duration = "0"
160+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
161+}
162+
163+func BenchmarkFormParse(b *testing.B) {
164+ m := newTestModel(b)
165+ m.mode = modeRecord
166+ m.region = "10,20,640x480"
167+ m.size = "1280x720"
168+ m.duration = "1m30s"
169+ b.ReportAllocs()
170+ for b.Loop() {
171+ if err := m.parse(); err != nil {
172+ b.Fatal(err)
173+ }
174+ }
175+}
@@ -0,0 +1,175 @@
1+package gui
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+var testOutputs = []ports.Output{
14+ {ID: 7, Name: "DP-1", Width: 2560, Height: 1440, Scale: 1},
15+ {ID: 9, Name: "", Width: 1920, Height: 1080, Scale: 2},
16+ {ID: 11},
17+}
18+
19+func newTestModel(t testing.TB) *model {
20+ t.Helper()
21+ m, err := newModel(testOutputs, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
22+ require.NoError(t, err)
23+ return m
24+}
25+
26+func TestParseScreenshotDefaults(t *testing.T) {
27+ m := newTestModel(t)
28+ require.NoError(t, m.parse())
29+ assert.Equal(t, ports.Selection{
30+ Mode: ports.Screenshot,
31+ Target: ports.Target{OutputID: 7},
32+ Path: "nefercap-20260102-030405.png",
33+ }, m.sel)
34+}
35+
36+func TestParseRecordDefaults(t *testing.T) {
37+ m := newTestModel(t)
38+ m.mode = modeRecord
39+ require.NoError(t, m.parse())
40+ assert.Equal(t, ports.Selection{
41+ Mode: ports.Record,
42+ Target: ports.Target{OutputID: 7},
43+ Path: "nefercap-20260102-030405.png",
44+ Video: ports.VideoSettings{FPS: 30},
45+ }, m.sel)
46+}
47+
48+func TestParseRecordFull(t *testing.T) {
49+ m := newTestModel(t)
50+ m.mode = modeRecord
51+ m.output = m.outputs[1].key
52+ m.path = " out.mp4 "
53+ m.region = " 10, 20, 640x480 "
54+ m.fps = "60"
55+ m.size = "1280X720"
56+ m.duration = "1m30s"
57+ require.NoError(t, m.parse())
58+ assert.Equal(t, ports.Selection{
59+ Mode: ports.Record,
60+ Target: ports.Target{OutputID: 9, Region: ports.Region{X: 10, Y: 20, Width: 640, Height: 480}},
61+ Path: "out.mp4",
62+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720},
63+ Duration: 90 * time.Second,
64+ }, m.sel)
65+}
66+
67+func TestParseInvalid(t *testing.T) {
68+ tests := []struct {
69+ name string
70+ edit func(*model)
71+ want error
72+ }{
73+ {"unknown output", func(m *model) { m.output = "nope" }, errOutput},
74+ {"empty path", func(m *model) { m.path = " " }, errPath},
75+ {"directory path", func(m *model) { m.path = "dir/" }, errPath},
76+ {"bad mode", func(m *model) { m.mode = "" }, errMode},
77+ {"region fields", func(m *model) { m.region = "1,2" }, errRegion},
78+ {"region no comma", func(m *model) { m.region = "1x2" }, errRegion},
79+ {"region zero width", func(m *model) { m.region = "0,0,0x5" }, errRegion},
80+ {"region zero height", func(m *model) { m.region = "0,0,5x0" }, errRegion},
81+ {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
82+ {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
83+ {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
84+ {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
85+ {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
86+ {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
87+ {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "241" }, errFPS},
88+ {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
89+ {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
90+ {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
91+ {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
92+ {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
93+ {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
94+ {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
95+ {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
96+ {"duration huge seconds", func(m *model) { m.mode = modeRecord; m.duration = "999999999999999999999" }, errDuration},
97+ }
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ m := newTestModel(t)
101+ tt.edit(m)
102+ assert.ErrorIs(t, m.parse(), tt.want)
103+ })
104+ }
105+}
106+
107+func TestScreenshotIgnoresRecordFields(t *testing.T) {
108+ m := newTestModel(t)
109+ m.fps, m.size, m.duration = "bad", "bad", "bad"
110+ require.NoError(t, m.parse())
111+ assert.Equal(t, ports.VideoSettings{}, m.sel.Video)
112+ assert.Zero(t, m.sel.Duration)
113+}
114+
115+func TestInvalidParseKeepsPreviousSelection(t *testing.T) {
116+ m := newTestModel(t)
117+ require.NoError(t, m.parse())
118+ want := m.sel
119+ m.path = ""
120+ require.Error(t, m.parse())
121+ assert.Equal(t, want, m.sel)
122+}
123+
124+func TestParseDuration(t *testing.T) {
125+ for in, want := range map[string]time.Duration{
126+ "": 0, "0": 0, "5": 5 * time.Second, " 90 ": 90 * time.Second,
127+ "1.5s": 1500 * time.Millisecond, "2m": 2 * time.Minute, "24h": 24 * time.Hour, "86400": 24 * time.Hour,
128+ } {
129+ got, ok := parseDuration(in)
130+ assert.True(t, ok, in)
131+ assert.Equal(t, want, got, in)
132+ }
133+}
134+
135+func TestParseUintBounds(t *testing.T) {
136+ n, ok := parseUint("16384", maxDim)
137+ assert.True(t, ok)
138+ assert.Equal(t, 16384, n)
139+ _, ok = parseUint("16385", maxDim)
140+ assert.False(t, ok)
141+ _, ok = parseUint("", maxDim)
142+ assert.False(t, ok)
143+}
144+
145+// The status line runs every frame and must not allocate for valid or invalid
146+// forms: parsing uses only the standard library on the model's own strings.
147+func TestFormAllocations(t *testing.T) {
148+ m := newTestModel(t)
149+ m.mode = modeRecord
150+ m.region = "10,20,640x480"
151+ m.size = "1280x720"
152+ m.duration = "1m30s"
153+ require.NoError(t, m.parse())
154+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
155+ assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
156+ m.fps = "abc"
157+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
158+ m.fps = "30"
159+ m.duration = "0"
160+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
161+}
162+
163+func BenchmarkFormParse(b *testing.B) {
164+ m := newTestModel(b)
165+ m.mode = modeRecord
166+ m.region = "10,20,640x480"
167+ m.size = "1280x720"
168+ m.duration = "1m30s"
169+ b.ReportAllocs()
170+ for b.Loop() {
171+ if err := m.parse(); err != nil {
172+ b.Fatal(err)
173+ }
174+ }
175+}
A internal/adapters/gui/gui.go +127 -0
@@ -0,0 +1,127 @@
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+// gone before an accepted selection is returned, so it cannot appear in the
34+// capture. Select must be called from one goroutine at a time.
35+func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
36+ if err := ctx.Err(); err != nil {
37+ return ports.Selection{}, false, err
38+ }
39+ m, err := newModel(outputs, time.Now())
40+ if err != nil {
41+ return ports.Selection{}, false, err
42+ }
43+ sheet, removeSheet, err := writeStyleSheet()
44+ if err != nil {
45+ return ports.Selection{}, false, err
46+ }
47+ defer removeSheet()
48+
49+ // The child context ends Run once the user decides; the parent stays
50+ // authoritative for real cancellation.
51+ child, cancel := context.WithCancel(ctx)
52+ defer cancel()
53+ m.cancel = cancel
54+ runErr := nefergui.Run(child, m, view,
55+ nefergui.Title("NeferCap"),
56+ nefergui.Size(640, m.windowHeight()),
57+ nefergui.Styles(sheet),
58+ )
59+ return m.result(ctx, runErr)
60+}
61+
62+// result maps Run's outcome. A Canceled error caused by our own accept or
63+// dismiss is not a failure, but any other error (including one joined with it,
64+// such as a surface Close failure) is reported and rejects the selection. When
65+// the parent context ended, its error is always reported and nothing is
66+// accepted.
67+func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
68+ if runErr != nil {
69+ if perr := parent.Err(); perr != nil {
70+ return ports.Selection{}, false, errors.Join(perr, dropCanceled(runErr))
71+ }
72+ if m.accepted || m.closed {
73+ runErr = dropCanceled(runErr)
74+ }
75+ if runErr != nil {
76+ return ports.Selection{}, false, runErr
77+ }
78+ }
79+ if m.accepted {
80+ return m.sel, true, nil
81+ }
82+ return ports.Selection{}, false, nil
83+}
84+
85+// dropCanceled removes bare context.Canceled leaves from err and joined errors.
86+func dropCanceled(err error) error {
87+ if err == nil || err == context.Canceled {
88+ return nil
89+ }
90+ j, ok := err.(interface{ Unwrap() []error })
91+ if !ok {
92+ return err
93+ }
94+ var keep []error
95+ for _, e := range j.Unwrap() {
96+ if e = dropCanceled(e); e != nil {
97+ keep = append(keep, e)
98+ }
99+ }
100+ if len(keep) == 1 {
101+ return keep[0]
102+ }
103+ return errors.Join(keep...)
104+}
105+
106+// writeStyleSheet stores the embedded stylesheet in a private temporary file
107+// because NeferGUI loads styles by path. It is read once when Run starts, so
108+// removal after Run returns is enough; a removal failure leaves only a small
109+// file in the temporary directory and is not worth failing a capture over.
110+func writeStyleSheet() (path string, remove func(), err error) {
111+ f, err := os.CreateTemp("", "nefercap-gui-*.css")
112+ if err != nil {
113+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
114+ }
115+ path = f.Name()
116+ remove = func() { _ = os.Remove(path) }
117+ if _, err := f.WriteString(styleSheet); err != nil {
118+ err = errors.Join(err, f.Close())
119+ remove()
120+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
121+ }
122+ if err := f.Close(); err != nil {
123+ remove()
124+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
125+ }
126+ return path, remove, nil
127+}
@@ -0,0 +1,127 @@
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+// gone before an accepted selection is returned, so it cannot appear in the
34+// capture. Select must be called from one goroutine at a time.
35+func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
36+ if err := ctx.Err(); err != nil {
37+ return ports.Selection{}, false, err
38+ }
39+ m, err := newModel(outputs, time.Now())
40+ if err != nil {
41+ return ports.Selection{}, false, err
42+ }
43+ sheet, removeSheet, err := writeStyleSheet()
44+ if err != nil {
45+ return ports.Selection{}, false, err
46+ }
47+ defer removeSheet()
48+
49+ // The child context ends Run once the user decides; the parent stays
50+ // authoritative for real cancellation.
51+ child, cancel := context.WithCancel(ctx)
52+ defer cancel()
53+ m.cancel = cancel
54+ runErr := nefergui.Run(child, m, view,
55+ nefergui.Title("NeferCap"),
56+ nefergui.Size(640, m.windowHeight()),
57+ nefergui.Styles(sheet),
58+ )
59+ return m.result(ctx, runErr)
60+}
61+
62+// result maps Run's outcome. A Canceled error caused by our own accept or
63+// dismiss is not a failure, but any other error (including one joined with it,
64+// such as a surface Close failure) is reported and rejects the selection. When
65+// the parent context ended, its error is always reported and nothing is
66+// accepted.
67+func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
68+ if runErr != nil {
69+ if perr := parent.Err(); perr != nil {
70+ return ports.Selection{}, false, errors.Join(perr, dropCanceled(runErr))
71+ }
72+ if m.accepted || m.closed {
73+ runErr = dropCanceled(runErr)
74+ }
75+ if runErr != nil {
76+ return ports.Selection{}, false, runErr
77+ }
78+ }
79+ if m.accepted {
80+ return m.sel, true, nil
81+ }
82+ return ports.Selection{}, false, nil
83+}
84+
85+// dropCanceled removes bare context.Canceled leaves from err and joined errors.
86+func dropCanceled(err error) error {
87+ if err == nil || err == context.Canceled {
88+ return nil
89+ }
90+ j, ok := err.(interface{ Unwrap() []error })
91+ if !ok {
92+ return err
93+ }
94+ var keep []error
95+ for _, e := range j.Unwrap() {
96+ if e = dropCanceled(e); e != nil {
97+ keep = append(keep, e)
98+ }
99+ }
100+ if len(keep) == 1 {
101+ return keep[0]
102+ }
103+ return errors.Join(keep...)
104+}
105+
106+// writeStyleSheet stores the embedded stylesheet in a private temporary file
107+// because NeferGUI loads styles by path. It is read once when Run starts, so
108+// removal after Run returns is enough; a removal failure leaves only a small
109+// file in the temporary directory and is not worth failing a capture over.
110+func writeStyleSheet() (path string, remove func(), err error) {
111+ f, err := os.CreateTemp("", "nefercap-gui-*.css")
112+ if err != nil {
113+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
114+ }
115+ path = f.Name()
116+ remove = func() { _ = os.Remove(path) }
117+ if _, err := f.WriteString(styleSheet); err != nil {
118+ err = errors.Join(err, f.Close())
119+ remove()
120+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
121+ }
122+ if err := f.Close(); err != nil {
123+ remove()
124+ return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
125+ }
126+ return path, remove, nil
127+}
A internal/adapters/gui/gui_test.go +244 -0
@@ -0,0 +1,244 @@
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, "0", 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 after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
165+ {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
166+ }
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ m := newTestModel(t)
170+ m.accepted, m.closed = tt.accepted, tt.closed
171+ m.sel = ports.Selection{Mode: ports.Record, Path: "x.mp4"}
172+ sel, ok, err := m.result(tt.parent, tt.runErr)
173+ assert.Equal(t, tt.wantOK, ok)
174+ if tt.wantOK {
175+ assert.Equal(t, m.sel, sel)
176+ } else {
177+ assert.Equal(t, ports.Selection{}, sel)
178+ }
179+ if tt.wantNoErr {
180+ assert.NoError(t, err)
181+ }
182+ if tt.wantErr != nil {
183+ assert.ErrorIs(t, err, tt.wantErr)
184+ }
185+ if tt.wantNotErr != nil {
186+ assert.NotErrorIs(t, err, tt.wantNotErr)
187+ }
188+ })
189+ }
190+}
191+
192+func TestDropCanceled(t *testing.T) {
193+ real := errors.New("real")
194+ assert.NoError(t, dropCanceled(nil))
195+ assert.NoError(t, dropCanceled(context.Canceled))
196+ assert.NoError(t, dropCanceled(errors.Join(context.Canceled, context.Canceled)))
197+ assert.Same(t, real, dropCanceled(errors.Join(context.Canceled, real)))
198+ wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
199+ assert.Equal(t, wrapped, dropCanceled(wrapped), "wrapped cancellation is not ours")
200+ both := dropCanceled(errors.Join(real, errors.New("second")))
201+ assert.ErrorIs(t, both, real)
202+}
203+
204+func TestWriteStyleSheet(t *testing.T) {
205+ t.Setenv("TMPDIR", t.TempDir())
206+ path, remove, err := writeStyleSheet()
207+ require.NoError(t, err)
208+ data, err := os.ReadFile(path)
209+ require.NoError(t, err)
210+ assert.Equal(t, styleSheet, string(data))
211+ info, err := os.Stat(path)
212+ require.NoError(t, err)
213+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
214+ remove()
215+ _, err = os.Stat(path)
216+ assert.ErrorIs(t, err, os.ErrNotExist)
217+}
218+
219+func TestStyleSheetUsesMonospace(t *testing.T) {
220+ assert.Contains(t, styleSheet, "monospace")
221+}
222+
223+func TestWriteStyleSheetFailure(t *testing.T) {
224+ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
225+ _, _, err := writeStyleSheet()
226+ assert.Error(t, err)
227+}
228+
229+func TestWindowHeightIsBounded(t *testing.T) {
230+ m := newTestModel(t)
231+ assert.Equal(t, 600+26*3, m.windowHeight())
232+ m.outputs = make([]outputChoice, 100)
233+ assert.Equal(t, 1000, m.windowHeight())
234+}
235+
236+func TestModeChangeNoop(t *testing.T) {
237+ m := newTestModel(t)
238+ m.notice = "keep"
239+ m.modeChanged()
240+ assert.Equal(t, "keep", m.notice)
241+ m.mode = modeRecord
242+ m.modeChanged()
243+ assert.Empty(t, m.notice)
244+}
@@ -0,0 +1,244 @@
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, "0", 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 after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
165+ {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
166+ }
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ m := newTestModel(t)
170+ m.accepted, m.closed = tt.accepted, tt.closed
171+ m.sel = ports.Selection{Mode: ports.Record, Path: "x.mp4"}
172+ sel, ok, err := m.result(tt.parent, tt.runErr)
173+ assert.Equal(t, tt.wantOK, ok)
174+ if tt.wantOK {
175+ assert.Equal(t, m.sel, sel)
176+ } else {
177+ assert.Equal(t, ports.Selection{}, sel)
178+ }
179+ if tt.wantNoErr {
180+ assert.NoError(t, err)
181+ }
182+ if tt.wantErr != nil {
183+ assert.ErrorIs(t, err, tt.wantErr)
184+ }
185+ if tt.wantNotErr != nil {
186+ assert.NotErrorIs(t, err, tt.wantNotErr)
187+ }
188+ })
189+ }
190+}
191+
192+func TestDropCanceled(t *testing.T) {
193+ real := errors.New("real")
194+ assert.NoError(t, dropCanceled(nil))
195+ assert.NoError(t, dropCanceled(context.Canceled))
196+ assert.NoError(t, dropCanceled(errors.Join(context.Canceled, context.Canceled)))
197+ assert.Same(t, real, dropCanceled(errors.Join(context.Canceled, real)))
198+ wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
199+ assert.Equal(t, wrapped, dropCanceled(wrapped), "wrapped cancellation is not ours")
200+ both := dropCanceled(errors.Join(real, errors.New("second")))
201+ assert.ErrorIs(t, both, real)
202+}
203+
204+func TestWriteStyleSheet(t *testing.T) {
205+ t.Setenv("TMPDIR", t.TempDir())
206+ path, remove, err := writeStyleSheet()
207+ require.NoError(t, err)
208+ data, err := os.ReadFile(path)
209+ require.NoError(t, err)
210+ assert.Equal(t, styleSheet, string(data))
211+ info, err := os.Stat(path)
212+ require.NoError(t, err)
213+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
214+ remove()
215+ _, err = os.Stat(path)
216+ assert.ErrorIs(t, err, os.ErrNotExist)
217+}
218+
219+func TestStyleSheetUsesMonospace(t *testing.T) {
220+ assert.Contains(t, styleSheet, "monospace")
221+}
222+
223+func TestWriteStyleSheetFailure(t *testing.T) {
224+ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
225+ _, _, err := writeStyleSheet()
226+ assert.Error(t, err)
227+}
228+
229+func TestWindowHeightIsBounded(t *testing.T) {
230+ m := newTestModel(t)
231+ assert.Equal(t, 600+26*3, m.windowHeight())
232+ m.outputs = make([]outputChoice, 100)
233+ assert.Equal(t, 1000, m.windowHeight())
234+}
235+
236+func TestModeChangeNoop(t *testing.T) {
237+ m := newTestModel(t)
238+ m.notice = "keep"
239+ m.modeChanged()
240+ assert.Equal(t, "keep", m.notice)
241+ m.mode = modeRecord
242+ m.modeChanged()
243+ assert.Empty(t, m.notice)
244+}
A internal/adapters/gui/model.go +186 -0
@@ -0,0 +1,186 @@
1+package gui
2+
3+import (
4+ "context"
5+ "os"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/nefergui"
11+
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+const (
16+ modeScreenshot = string(ports.Screenshot)
17+ modeRecord = string(ports.Record)
18+
19+ statusReady = "ready: press Capture (or Enter in a field)"
20+ noticeExists = "! file exists: choose another path"
21+
22+ // The panel is a regular toplevel: the compositor places it, and capture
23+ // support is limited to what the capture adapter offers.
24+ warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
25+ "of the current workspace. Off-screen sources and the cursor cannot be captured."
26+ recordNote = "duration 0 records until Ctrl+C in the terminal."
27+ stampLayout = "20060102-150405"
28+)
29+
30+// outputChoice is one precomputed radio entry.
31+type outputChoice struct {
32+ id uint32
33+ key, label string
34+ opts []nefergui.ButtonOption
35+}
36+
37+// model is owned by the single Run view; nothing else touches it while the
38+// window is open.
39+type model struct {
40+ outputs []outputChoice
41+ output string // key of the selected outputChoice
42+ mode string
43+ // lastMode detects mode changes to swap an untouched default path.
44+ lastMode string
45+
46+ path, region, fps, size, duration string
47+ defaultShot, defaultRec string
48+
49+ // notice is a submit-time problem shown until the path or mode changes.
50+ notice string
51+
52+ sel ports.Selection
53+ accepted bool
54+ closed bool
55+ cancel context.CancelFunc
56+}
57+
58+// newModel precomputes output labels, keys and default names once.
59+func newModel(outputs []ports.Output, now time.Time) (*model, error) {
60+ if len(outputs) == 0 {
61+ return nil, errNoOutputs
62+ }
63+ stamp := now.Format(stampLayout)
64+ m := &model{
65+ outputs: make([]outputChoice, len(outputs)),
66+ mode: modeScreenshot,
67+ lastMode: modeScreenshot,
68+ defaultShot: "nefercap-" + stamp + ".png",
69+ defaultRec: "nefercap-" + stamp + ".mp4",
70+ fps: "30",
71+ duration: "0",
72+ }
73+ m.path = m.defaultShot
74+ var b strings.Builder
75+ for i, o := range outputs {
76+ b.Reset()
77+ if o.Name != "" {
78+ b.WriteString(o.Name)
79+ } else {
80+ b.WriteString("output ")
81+ b.WriteString(strconv.FormatUint(uint64(o.ID), 10))
82+ }
83+ if o.Width > 0 && o.Height > 0 {
84+ b.WriteString(" ")
85+ b.WriteString(strconv.Itoa(o.Width))
86+ b.WriteByte('x')
87+ b.WriteString(strconv.Itoa(o.Height))
88+ }
89+ if o.Scale > 1 {
90+ b.WriteString(" scale ")
91+ b.WriteString(strconv.Itoa(o.Scale))
92+ }
93+ key := strconv.Itoa(i)
94+ m.outputs[i] = outputChoice{
95+ id: o.ID,
96+ key: key,
97+ label: b.String(),
98+ opts: []nefergui.ButtonOption{nefergui.Key("out-" + key)},
99+ }
100+ }
101+ m.output = m.outputs[0].key
102+ return m, nil
103+}
104+
105+// outputIndex returns the selected output's index, or -1.
106+func (m *model) outputIndex() int {
107+ for i := range m.outputs {
108+ if m.outputs[i].key == m.output {
109+ return i
110+ }
111+ }
112+ return -1
113+}
114+
115+// windowHeight sizes the initial window for the output list.
116+func (m *model) windowHeight() int {
117+ return min(600+26*len(m.outputs), 1000)
118+}
119+
120+// status reports the live validation result. submittable is false while the
121+// form is invalid; ok is false whenever the message is a problem, including a
122+// submit-time notice that still lets the user retry.
123+func (m *model) status() (text string, submittable, ok bool) {
124+ if err := m.parse(); err != nil {
125+ return err.Error(), false, false
126+ }
127+ if m.notice != "" {
128+ return m.notice, true, false
129+ }
130+ return statusReady, true, true
131+}
132+
133+// modeChanged swaps the default file name when the user has not edited it.
134+func (m *model) modeChanged() {
135+ if m.mode == m.lastMode {
136+ return
137+ }
138+ if m.path == m.defaultFor(m.lastMode) {
139+ m.path = m.defaultFor(m.mode)
140+ }
141+ m.lastMode = m.mode
142+ m.notice = ""
143+}
144+
145+func (m *model) defaultFor(mode string) string {
146+ if mode == modeRecord {
147+ return m.defaultRec
148+ }
149+ return m.defaultShot
150+}
151+
152+// pathChanged clears stale submit notices.
153+func (m *model) pathChanged() { m.notice = "" }
154+
155+// submit validates the form; on success it records the selection and closes
156+// the window by cancelling Run's child context. Repeated calls are ignored.
157+func (m *model) submit() {
158+ if m.accepted || m.closed {
159+ return
160+ }
161+ if err := m.parse(); err != nil {
162+ return
163+ }
164+ if _, err := os.Lstat(m.sel.Path); err == nil {
165+ m.notice = noticeExists
166+ return
167+ }
168+ m.notice = ""
169+ m.accepted = true
170+ m.stop()
171+}
172+
173+// dismiss closes the window without a selection.
174+func (m *model) dismiss() {
175+ if m.accepted || m.closed {
176+ return
177+ }
178+ m.closed = true
179+ m.stop()
180+}
181+
182+func (m *model) stop() {
183+ if m.cancel != nil {
184+ m.cancel()
185+ }
186+}
@@ -0,0 +1,186 @@
1+package gui
2+
3+import (
4+ "context"
5+ "os"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/nefergui"
11+
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+const (
16+ modeScreenshot = string(ports.Screenshot)
17+ modeRecord = string(ports.Record)
18+
19+ statusReady = "ready: press Capture (or Enter in a field)"
20+ noticeExists = "! file exists: choose another path"
21+
22+ // The panel is a regular toplevel: the compositor places it, and capture
23+ // support is limited to what the capture adapter offers.
24+ warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
25+ "of the current workspace. Off-screen sources and the cursor cannot be captured."
26+ recordNote = "duration 0 records until Ctrl+C in the terminal."
27+ stampLayout = "20060102-150405"
28+)
29+
30+// outputChoice is one precomputed radio entry.
31+type outputChoice struct {
32+ id uint32
33+ key, label string
34+ opts []nefergui.ButtonOption
35+}
36+
37+// model is owned by the single Run view; nothing else touches it while the
38+// window is open.
39+type model struct {
40+ outputs []outputChoice
41+ output string // key of the selected outputChoice
42+ mode string
43+ // lastMode detects mode changes to swap an untouched default path.
44+ lastMode string
45+
46+ path, region, fps, size, duration string
47+ defaultShot, defaultRec string
48+
49+ // notice is a submit-time problem shown until the path or mode changes.
50+ notice string
51+
52+ sel ports.Selection
53+ accepted bool
54+ closed bool
55+ cancel context.CancelFunc
56+}
57+
58+// newModel precomputes output labels, keys and default names once.
59+func newModel(outputs []ports.Output, now time.Time) (*model, error) {
60+ if len(outputs) == 0 {
61+ return nil, errNoOutputs
62+ }
63+ stamp := now.Format(stampLayout)
64+ m := &model{
65+ outputs: make([]outputChoice, len(outputs)),
66+ mode: modeScreenshot,
67+ lastMode: modeScreenshot,
68+ defaultShot: "nefercap-" + stamp + ".png",
69+ defaultRec: "nefercap-" + stamp + ".mp4",
70+ fps: "30",
71+ duration: "0",
72+ }
73+ m.path = m.defaultShot
74+ var b strings.Builder
75+ for i, o := range outputs {
76+ b.Reset()
77+ if o.Name != "" {
78+ b.WriteString(o.Name)
79+ } else {
80+ b.WriteString("output ")
81+ b.WriteString(strconv.FormatUint(uint64(o.ID), 10))
82+ }
83+ if o.Width > 0 && o.Height > 0 {
84+ b.WriteString(" ")
85+ b.WriteString(strconv.Itoa(o.Width))
86+ b.WriteByte('x')
87+ b.WriteString(strconv.Itoa(o.Height))
88+ }
89+ if o.Scale > 1 {
90+ b.WriteString(" scale ")
91+ b.WriteString(strconv.Itoa(o.Scale))
92+ }
93+ key := strconv.Itoa(i)
94+ m.outputs[i] = outputChoice{
95+ id: o.ID,
96+ key: key,
97+ label: b.String(),
98+ opts: []nefergui.ButtonOption{nefergui.Key("out-" + key)},
99+ }
100+ }
101+ m.output = m.outputs[0].key
102+ return m, nil
103+}
104+
105+// outputIndex returns the selected output's index, or -1.
106+func (m *model) outputIndex() int {
107+ for i := range m.outputs {
108+ if m.outputs[i].key == m.output {
109+ return i
110+ }
111+ }
112+ return -1
113+}
114+
115+// windowHeight sizes the initial window for the output list.
116+func (m *model) windowHeight() int {
117+ return min(600+26*len(m.outputs), 1000)
118+}
119+
120+// status reports the live validation result. submittable is false while the
121+// form is invalid; ok is false whenever the message is a problem, including a
122+// submit-time notice that still lets the user retry.
123+func (m *model) status() (text string, submittable, ok bool) {
124+ if err := m.parse(); err != nil {
125+ return err.Error(), false, false
126+ }
127+ if m.notice != "" {
128+ return m.notice, true, false
129+ }
130+ return statusReady, true, true
131+}
132+
133+// modeChanged swaps the default file name when the user has not edited it.
134+func (m *model) modeChanged() {
135+ if m.mode == m.lastMode {
136+ return
137+ }
138+ if m.path == m.defaultFor(m.lastMode) {
139+ m.path = m.defaultFor(m.mode)
140+ }
141+ m.lastMode = m.mode
142+ m.notice = ""
143+}
144+
145+func (m *model) defaultFor(mode string) string {
146+ if mode == modeRecord {
147+ return m.defaultRec
148+ }
149+ return m.defaultShot
150+}
151+
152+// pathChanged clears stale submit notices.
153+func (m *model) pathChanged() { m.notice = "" }
154+
155+// submit validates the form; on success it records the selection and closes
156+// the window by cancelling Run's child context. Repeated calls are ignored.
157+func (m *model) submit() {
158+ if m.accepted || m.closed {
159+ return
160+ }
161+ if err := m.parse(); err != nil {
162+ return
163+ }
164+ if _, err := os.Lstat(m.sel.Path); err == nil {
165+ m.notice = noticeExists
166+ return
167+ }
168+ m.notice = ""
169+ m.accepted = true
170+ m.stop()
171+}
172+
173+// dismiss closes the window without a selection.
174+func (m *model) dismiss() {
175+ if m.accepted || m.closed {
176+ return
177+ }
178+ m.closed = true
179+ m.stop()
180+}
181+
182+func (m *model) stop() {
183+ if m.cancel != nil {
184+ m.cancel()
185+ }
186+}
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 +36 -0
@@ -0,0 +1,36 @@
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+}
9+heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
10+text.section { margin-top: 8px; color: #8b949e; }
11+text.note { color: #8b949e; font-size: 12px; }
12+text.status { margin-top: 8px; color: #8b949e; }
13+text.status.bad { color: #e6edf3; font-weight: 700; }
14+text.field-label { width: 96px; flex-shrink: 0; color: #8b949e; }
15+column.group {
16+ display: flex; flex-direction: column; row-gap: 2px;
17+ box-sizing: border-box; padding: 6px 8px; border: 1px solid #2a313b;
18+}
19+row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
20+row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
21+separator { background: #2a313b; }
22+radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
23+radio:hover { color: #e6edf3; }
24+radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
25+input {
26+ box-sizing: border-box; flex-grow: 1; min-width: 0; padding: 4px 8px;
27+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
28+}
29+input:focus { border-color: #4fd1a5; }
30+button {
31+ box-sizing: border-box; padding: 6px 16px;
32+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
33+}
34+button:hover { border-color: #4fd1a5; }
35+button.primary { background: #4fd1a5; color: #0d1014; border-color: #4fd1a5; font-weight: 700; }
36+button:disabled, button.primary:disabled { background: #12161c; color: #5b6570; border-color: #2a313b; }
@@ -0,0 +1,36 @@
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+}
9+heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
10+text.section { margin-top: 8px; color: #8b949e; }
11+text.note { color: #8b949e; font-size: 12px; }
12+text.status { margin-top: 8px; color: #8b949e; }
13+text.status.bad { color: #e6edf3; font-weight: 700; }
14+text.field-label { width: 96px; flex-shrink: 0; color: #8b949e; }
15+column.group {
16+ display: flex; flex-direction: column; row-gap: 2px;
17+ box-sizing: border-box; padding: 6px 8px; border: 1px solid #2a313b;
18+}
19+row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
20+row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
21+separator { background: #2a313b; }
22+radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
23+radio:hover { color: #e6edf3; }
24+radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
25+input {
26+ box-sizing: border-box; flex-grow: 1; min-width: 0; padding: 4px 8px;
27+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
28+}
29+input:focus { border-color: #4fd1a5; }
30+button {
31+ box-sizing: border-box; padding: 6px 16px;
32+ background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
33+}
34+button:hover { border-color: #4fd1a5; }
35+button.primary { background: #4fd1a5; color: #0d1014; border-color: #4fd1a5; font-weight: 700; }
36+button:disabled, button.primary:disabled { background: #12161c; color: #5b6570; border-color: #2a313b; }
A internal/adapters/gui/view.go +136 -0
@@ -0,0 +1,136 @@
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", "1-240")
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+ root.Heading("NeferCap", titleOpts...)
76+
77+ root.Text("output", outputTitle...)
78+ outs := root.Column(outputsGroup...)
79+ for i := range m.outputs {
80+ o := &m.outputs[i]
81+ outs.Radio(o.label, o.key, &m.output, o.opts...)
82+ }
83+
84+ root.Text("capture", modeTitle...)
85+ modes := root.Column(modeGroup...)
86+ modes.Radio("screenshot", modeScreenshot, &m.mode, shotOpts...)
87+ modes.Radio("record", modeRecord, &m.mode, recOpts...)
88+ m.modeChanged()
89+
90+ submit := false
91+ root.Text("target", targetTitle...)
92+ target := root.Column(targetGroup...)
93+ changed, entered := fileField.show(target, &m.path)
94+ if changed {
95+ m.pathChanged()
96+ }
97+ submit = submit || entered
98+ _, entered = regionField.show(target, &m.region)
99+ submit = submit || entered
100+
101+ if m.mode == modeRecord {
102+ rec := root.Column(recordGroup...)
103+ for _, e := range [...]struct {
104+ f *field
105+ v *string
106+ }{{fpsField, &m.fps}, {sizeField, &m.size}, {durationField, &m.duration}} {
107+ _, entered = e.f.show(rec, e.v)
108+ submit = submit || entered
109+ }
110+ rec.Text(recordNote, recordNoteOp...)
111+ }
112+
113+ root.Text(warning, warningOpts...)
114+
115+ text, submittable, ok := m.status()
116+ if ok {
117+ root.Text(text, statusGood...)
118+ } else {
119+ root.Text(text, statusBad...)
120+ }
121+
122+ actions := root.Row(actionsOpts...)
123+ capture := captureOff
124+ if submittable {
125+ capture = captureOn
126+ }
127+ if actions.Button("Capture", capture...).Activated() && submittable {
128+ submit = true
129+ }
130+ if actions.Button("Close", closeOpts...).Activated() {
131+ m.dismiss()
132+ }
133+ if submit {
134+ m.submit()
135+ }
136+}
@@ -0,0 +1,136 @@
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", "1-240")
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+ root.Heading("NeferCap", titleOpts...)
76+
77+ root.Text("output", outputTitle...)
78+ outs := root.Column(outputsGroup...)
79+ for i := range m.outputs {
80+ o := &m.outputs[i]
81+ outs.Radio(o.label, o.key, &m.output, o.opts...)
82+ }
83+
84+ root.Text("capture", modeTitle...)
85+ modes := root.Column(modeGroup...)
86+ modes.Radio("screenshot", modeScreenshot, &m.mode, shotOpts...)
87+ modes.Radio("record", modeRecord, &m.mode, recOpts...)
88+ m.modeChanged()
89+
90+ submit := false
91+ root.Text("target", targetTitle...)
92+ target := root.Column(targetGroup...)
93+ changed, entered := fileField.show(target, &m.path)
94+ if changed {
95+ m.pathChanged()
96+ }
97+ submit = submit || entered
98+ _, entered = regionField.show(target, &m.region)
99+ submit = submit || entered
100+
101+ if m.mode == modeRecord {
102+ rec := root.Column(recordGroup...)
103+ for _, e := range [...]struct {
104+ f *field
105+ v *string
106+ }{{fpsField, &m.fps}, {sizeField, &m.size}, {durationField, &m.duration}} {
107+ _, entered = e.f.show(rec, e.v)
108+ submit = submit || entered
109+ }
110+ rec.Text(recordNote, recordNoteOp...)
111+ }
112+
113+ root.Text(warning, warningOpts...)
114+
115+ text, submittable, ok := m.status()
116+ if ok {
117+ root.Text(text, statusGood...)
118+ } else {
119+ root.Text(text, statusBad...)
120+ }
121+
122+ actions := root.Row(actionsOpts...)
123+ capture := captureOff
124+ if submittable {
125+ capture = captureOn
126+ }
127+ if actions.Button("Capture", capture...).Activated() && submittable {
128+ submit = true
129+ }
130+ if actions.Button("Close", closeOpts...).Activated() {
131+ m.dismiss()
132+ }
133+ if submit {
134+ m.submit()
135+ }
136+}

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