fix(gui): align panel limits with ports and harden submit checks
- commit
- 4c2a7d6c8b50ef94a9adb1b0f30755844f4bfee3
- author
- brice · · 2026-09-30 08:30 UTC
- committer
- brice · · 2026-09-30 14:48 UTC
- parent
- b11e89c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +284 -88
M internal/adapters/gui/form.go +55 -17
| @@ -2,28 +2,31 @@ package gui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 5 | 7 | "strings" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/nefercap/internal/ports" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 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 | - | ) | |
| 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 | |
| 17 | 16 | ||
| 18 | - | // Validation messages are static so status rendering never allocates. | |
| 17 | + | // Validation messages are built once, so status rendering never allocates. | |
| 19 | 18 | var ( | |
| 20 | 19 | errOutput = errors.New("! select an output") | |
| 21 | 20 | errMode = errors.New("! select screenshot or record") | |
| 22 | 21 | 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") | |
| 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) | |
| 26 | 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) | |
| 27 | 30 | ) | |
| 28 | 31 | ||
| 29 | 32 | // parse validates the form and, on success, stores the result in m.sel. It | |
| @@ -50,7 +53,7 @@ func (m *model) parse() error { | |||
| 50 | 53 | sel.Mode = ports.Screenshot | |
| 51 | 54 | case modeRecord: | |
| 52 | 55 | sel.Mode = ports.Record | |
| 53 | - | fps, ok := parseUint(m.fps, maxFPS) | |
| 56 | + | fps, ok := parseUint(m.fps, ports.MaxFPS) | |
| 54 | 57 | if !ok || fps < 1 { | |
| 55 | 58 | return errFPS | |
| 56 | 59 | } | |
| @@ -58,7 +61,10 @@ func (m *model) parse() error { | |||
| 58 | 61 | if !ok { | |
| 59 | 62 | return errSize | |
| 60 | 63 | } | |
| 61 | - | dur, ok := parseDuration(m.duration) | |
| 64 | + | if err := checkVideoSize(w, h); err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | dur, ok := m.parsedDuration() | |
| 62 | 68 | if !ok { | |
| 63 | 69 | return errDuration | |
| 64 | 70 | } | |
| @@ -71,6 +77,35 @@ func (m *model) parse() error { | |||
| 71 | 77 | return nil | |
| 72 | 78 | } | |
| 73 | 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 | + | ||
| 74 | 109 | // parseUint parses plain decimal digits no greater than limit. | |
| 75 | 110 | func parseUint(s string, limit int) (int, bool) { | |
| 76 | 111 | s = strings.TrimSpace(s) | |
| @@ -92,6 +127,9 @@ func parseUint(s string, limit int) (int, bool) { | |||
| 92 | 127 | } | |
| 93 | 128 | ||
| 94 | 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. | |
| 95 | 133 | func parseRegion(s string) (ports.Region, bool) { | |
| 96 | 134 | s = strings.TrimSpace(s) | |
| 97 | 135 | if s == "" { | |
| @@ -105,10 +143,10 @@ func parseRegion(s string) (ports.Region, bool) { | |||
| 105 | 143 | if !ok { | |
| 106 | 144 | return ports.Region{}, false | |
| 107 | 145 | } | |
| 108 | - | x, ok1 := parseUint(xs, maxDim) | |
| 109 | - | y, ok2 := parseUint(ys, maxDim) | |
| 146 | + | x, ok1 := parseUint(xs, ports.MaxDimension) | |
| 147 | + | y, ok2 := parseUint(ys, ports.MaxDimension) | |
| 110 | 148 | w, h, ok3 := parseSize(size) | |
| 111 | - | if !ok1 || !ok2 || !ok3 || w == 0 { | |
| 149 | + | if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension { | |
| 112 | 150 | return ports.Region{}, false | |
| 113 | 151 | } | |
| 114 | 152 | return ports.Region{X: x, Y: y, Width: w, Height: h}, true | |
| @@ -124,8 +162,8 @@ func parseSize(s string) (w, h int, ok bool) { | |||
| 124 | 162 | if i < 0 { | |
| 125 | 163 | return 0, 0, false | |
| 126 | 164 | } | |
| 127 | - | w, ok1 := parseUint(s[:i], maxDim) | |
| 128 | - | h, ok2 := parseUint(s[i+1:], maxDim) | |
| 165 | + | w, ok1 := parseUint(s[:i], ports.MaxDimension) | |
| 166 | + | h, ok2 := parseUint(s[i+1:], ports.MaxDimension) | |
| 129 | 167 | if !ok1 || !ok2 || w < 1 || h < 1 { | |
| 130 | 168 | return 0, 0, false | |
| 131 | 169 | } | |
| @@ -2,28 +2,31 @@ package gui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 5 | "strings" | 7 | "strings" | ||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| 8 | "github.com/bnema/nefercap/internal/ports" | 10 | "github.com/bnema/nefercap/internal/ports" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | - | // Form limits. Bounds are generous but keep every value far from int overflow. | 13 | + | // Form limits. Dimension, frame-size and fps bounds come from ports so the |
| 12 | - | const ( | 14 | + | // panel cannot accept what the capture pipeline rejects. |
| 13 | - | maxDim = 16384 | 15 | + | const maxDuration = 24 * time.Hour |
| 14 | - | maxFPS = 240 | |||
| 15 | - | maxDuration = 24 * time.Hour | |||
| 16 | - | ) | |||
| 17 | 16 | ||||
| 18 | - | // Validation messages are static so status rendering never allocates. | 17 | + | // Validation messages are built once, so status rendering never allocates. |
| 19 | var ( | 18 | var ( | ||
| 20 | errOutput = errors.New("! select an output") | 19 | errOutput = errors.New("! select an output") | ||
| 21 | errMode = errors.New("! select screenshot or record") | 20 | errMode = errors.New("! select screenshot or record") | ||
| 22 | errPath = errors.New("! file must name a path (not empty, not a directory)") | 21 | 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") | 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) |
| 24 | - | errFPS = errors.New("! fps must be a whole number from 1 to 240") | 23 | + | errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS) |
| 25 | - | errSize = errors.New("! size must be WxH with both > 0, or empty for source size") | 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) | |||
| 26 | errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C") | 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) | |||
| 27 | ) | 30 | ) | ||
| 28 | 31 | ||||
| 29 | // parse validates the form and, on success, stores the result in m.sel. It | 32 | // parse validates the form and, on success, stores the result in m.sel. It | ||
| @@ -50,7 +53,7 @@ func (m *model) parse() error { | |||||
| 50 | sel.Mode = ports.Screenshot | 53 | sel.Mode = ports.Screenshot | ||
| 51 | case modeRecord: | 54 | case modeRecord: | ||
| 52 | sel.Mode = ports.Record | 55 | sel.Mode = ports.Record | ||
| 53 | - | fps, ok := parseUint(m.fps, maxFPS) | 56 | + | fps, ok := parseUint(m.fps, ports.MaxFPS) |
| 54 | if !ok || fps < 1 { | 57 | if !ok || fps < 1 { | ||
| 55 | return errFPS | 58 | return errFPS | ||
| 56 | } | 59 | } | ||
| @@ -58,7 +61,10 @@ func (m *model) parse() error { | |||||
| 58 | if !ok { | 61 | if !ok { | ||
| 59 | return errSize | 62 | return errSize | ||
| 60 | } | 63 | } | ||
| 61 | - | dur, ok := parseDuration(m.duration) | 64 | + | if err := checkVideoSize(w, h); err != nil { |
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | dur, ok := m.parsedDuration() | |||
| 62 | if !ok { | 68 | if !ok { | ||
| 63 | return errDuration | 69 | return errDuration | ||
| 64 | } | 70 | } | ||
| @@ -71,6 +77,35 @@ func (m *model) parse() error { | |||||
| 71 | return nil | 77 | return nil | ||
| 72 | } | 78 | } | ||
| 73 | 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 | + | ||||
| 74 | // parseUint parses plain decimal digits no greater than limit. | 109 | // parseUint parses plain decimal digits no greater than limit. | ||
| 75 | func parseUint(s string, limit int) (int, bool) { | 110 | func parseUint(s string, limit int) (int, bool) { | ||
| 76 | s = strings.TrimSpace(s) | 111 | s = strings.TrimSpace(s) | ||
| @@ -92,6 +127,9 @@ func parseUint(s string, limit int) (int, bool) { | |||||
| 92 | } | 127 | } | ||
| 93 | 128 | ||||
| 94 | // parseRegion parses "X,Y,WxH". Empty means the full output (zero Region). | 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. | |||
| 95 | func parseRegion(s string) (ports.Region, bool) { | 133 | func parseRegion(s string) (ports.Region, bool) { | ||
| 96 | s = strings.TrimSpace(s) | 134 | s = strings.TrimSpace(s) | ||
| 97 | if s == "" { | 135 | if s == "" { | ||
| @@ -105,10 +143,10 @@ func parseRegion(s string) (ports.Region, bool) { | |||||
| 105 | if !ok { | 143 | if !ok { | ||
| 106 | return ports.Region{}, false | 144 | return ports.Region{}, false | ||
| 107 | } | 145 | } | ||
| 108 | - | x, ok1 := parseUint(xs, maxDim) | 146 | + | x, ok1 := parseUint(xs, ports.MaxDimension) |
| 109 | - | y, ok2 := parseUint(ys, maxDim) | 147 | + | y, ok2 := parseUint(ys, ports.MaxDimension) |
| 110 | w, h, ok3 := parseSize(size) | 148 | w, h, ok3 := parseSize(size) | ||
| 111 | - | if !ok1 || !ok2 || !ok3 || w == 0 { | 149 | + | if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension { |
| 112 | return ports.Region{}, false | 150 | return ports.Region{}, false | ||
| 113 | } | 151 | } | ||
| 114 | return ports.Region{X: x, Y: y, Width: w, Height: h}, true | 152 | return ports.Region{X: x, Y: y, Width: w, Height: h}, true | ||
| @@ -124,8 +162,8 @@ func parseSize(s string) (w, h int, ok bool) { | |||||
| 124 | if i < 0 { | 162 | if i < 0 { | ||
| 125 | return 0, 0, false | 163 | return 0, 0, false | ||
| 126 | } | 164 | } | ||
| 127 | - | w, ok1 := parseUint(s[:i], maxDim) | 165 | + | w, ok1 := parseUint(s[:i], ports.MaxDimension) |
| 128 | - | h, ok2 := parseUint(s[i+1:], maxDim) | 166 | + | h, ok2 := parseUint(s[i+1:], ports.MaxDimension) |
| 129 | if !ok1 || !ok2 || w < 1 || h < 1 { | 167 | if !ok1 || !ok2 || w < 1 || h < 1 { | ||
| 130 | return 0, 0, false | 168 | return 0, 0, false | ||
| 131 | } | 169 | } | ||
M internal/adapters/gui/form_test.go +67 -8
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package gui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -38,10 +39,11 @@ func TestParseRecordDefaults(t *testing.T) { | |||
| 38 | 39 | m.mode = modeRecord | |
| 39 | 40 | require.NoError(t, m.parse()) | |
| 40 | 41 | 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}, | |
| 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, | |
| 45 | 47 | }, m.sel) | |
| 46 | 48 | } | |
| 47 | 49 | ||
| @@ -81,15 +83,20 @@ func TestParseInvalid(t *testing.T) { | |||
| 81 | 83 | {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion}, | |
| 82 | 84 | {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion}, | |
| 83 | 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}, | |
| 84 | 88 | {"region empty size", func(m *model) { m.region = "0,0," }, errRegion}, | |
| 85 | 89 | {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS}, | |
| 86 | 90 | {"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}, | |
| 91 | + | {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS}, | |
| 88 | 92 | {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS}, | |
| 89 | 93 | {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS}, | |
| 90 | 94 | {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize}, | |
| 91 | 95 | {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize}, | |
| 92 | 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}, | |
| 93 | 100 | {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration}, | |
| 94 | 101 | {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration}, | |
| 95 | 102 | {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration}, | |
| @@ -104,6 +111,46 @@ func TestParseInvalid(t *testing.T) { | |||
| 104 | 111 | } | |
| 105 | 112 | } | |
| 106 | 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 | + | ||
| 107 | 154 | func TestScreenshotIgnoresRecordFields(t *testing.T) { | |
| 108 | 155 | m := newTestModel(t) | |
| 109 | 156 | m.fps, m.size, m.duration = "bad", "bad", "bad" | |
| @@ -133,12 +180,12 @@ func TestParseDuration(t *testing.T) { | |||
| 133 | 180 | } | |
| 134 | 181 | ||
| 135 | 182 | func TestParseUintBounds(t *testing.T) { | |
| 136 | - | n, ok := parseUint("16384", maxDim) | |
| 183 | + | n, ok := parseUint("16384", ports.MaxDimension) | |
| 137 | 184 | assert.True(t, ok) | |
| 138 | 185 | assert.Equal(t, 16384, n) | |
| 139 | - | _, ok = parseUint("16385", maxDim) | |
| 186 | + | _, ok = parseUint("16385", ports.MaxDimension) | |
| 140 | 187 | assert.False(t, ok) | |
| 141 | - | _, ok = parseUint("", maxDim) | |
| 188 | + | _, ok = parseUint("", ports.MaxDimension) | |
| 142 | 189 | assert.False(t, ok) | |
| 143 | 190 | } | |
| 144 | 191 | ||
| @@ -152,12 +199,24 @@ func TestFormAllocations(t *testing.T) { | |||
| 152 | 199 | m.duration = "1m30s" | |
| 153 | 200 | require.NoError(t, m.parse()) | |
| 154 | 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" | |
| 155 | 205 | assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status") | |
| 156 | 206 | m.fps = "abc" | |
| 157 | 207 | assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form") | |
| 158 | 208 | m.fps = "30" | |
| 159 | 209 | m.duration = "0" | |
| 160 | 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) | |
| 161 | 220 | } | |
| 162 | 221 | ||
| 163 | 222 | func BenchmarkFormParse(b *testing.B) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package gui | 1 | package gui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strconv" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -38,10 +39,11 @@ func TestParseRecordDefaults(t *testing.T) { | |||||
| 38 | m.mode = modeRecord | 39 | m.mode = modeRecord | ||
| 39 | require.NoError(t, m.parse()) | 40 | require.NoError(t, m.parse()) | ||
| 40 | assert.Equal(t, ports.Selection{ | 41 | assert.Equal(t, ports.Selection{ | ||
| 41 | - | Mode: ports.Record, | 42 | + | Mode: ports.Record, |
| 42 | - | Target: ports.Target{OutputID: 7}, | 43 | + | Target: ports.Target{OutputID: 7}, |
| 43 | - | Path: "nefercap-20260102-030405.png", | 44 | + | Path: "nefercap-20260102-030405.png", |
| 44 | - | Video: ports.VideoSettings{FPS: 30}, | 45 | + | Video: ports.VideoSettings{FPS: 30}, |
| 46 | + | Duration: 10 * time.Second, | |||
| 45 | }, m.sel) | 47 | }, m.sel) | ||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| @@ -81,15 +83,20 @@ func TestParseInvalid(t *testing.T) { | |||||
| 81 | {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion}, | 83 | {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion}, | ||
| 82 | {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion}, | 84 | {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion}, | ||
| 83 | {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, 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}, | |||
| 84 | {"region empty size", func(m *model) { m.region = "0,0," }, errRegion}, | 88 | {"region empty size", func(m *model) { m.region = "0,0," }, errRegion}, | ||
| 85 | {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS}, | 89 | {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS}, | ||
| 86 | {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS}, | 90 | {"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}, | 91 | + | {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS}, |
| 88 | {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS}, | 92 | {"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}, | 93 | {"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}, | 94 | {"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}, | 95 | {"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}, | 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}, | |||
| 93 | {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration}, | 100 | {"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}, | 101 | {"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}, | 102 | {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration}, | ||
| @@ -104,6 +111,46 @@ func TestParseInvalid(t *testing.T) { | |||||
| 104 | } | 111 | } | ||
| 105 | } | 112 | } | ||
| 106 | 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 | + | ||||
| 107 | func TestScreenshotIgnoresRecordFields(t *testing.T) { | 154 | func TestScreenshotIgnoresRecordFields(t *testing.T) { | ||
| 108 | m := newTestModel(t) | 155 | m := newTestModel(t) | ||
| 109 | m.fps, m.size, m.duration = "bad", "bad", "bad" | 156 | m.fps, m.size, m.duration = "bad", "bad", "bad" | ||
| @@ -133,12 +180,12 @@ func TestParseDuration(t *testing.T) { | |||||
| 133 | } | 180 | } | ||
| 134 | 181 | ||||
| 135 | func TestParseUintBounds(t *testing.T) { | 182 | func TestParseUintBounds(t *testing.T) { | ||
| 136 | - | n, ok := parseUint("16384", maxDim) | 183 | + | n, ok := parseUint("16384", ports.MaxDimension) |
| 137 | assert.True(t, ok) | 184 | assert.True(t, ok) | ||
| 138 | assert.Equal(t, 16384, n) | 185 | assert.Equal(t, 16384, n) | ||
| 139 | - | _, ok = parseUint("16385", maxDim) | 186 | + | _, ok = parseUint("16385", ports.MaxDimension) |
| 140 | assert.False(t, ok) | 187 | assert.False(t, ok) | ||
| 141 | - | _, ok = parseUint("", maxDim) | 188 | + | _, ok = parseUint("", ports.MaxDimension) |
| 142 | assert.False(t, ok) | 189 | assert.False(t, ok) | ||
| 143 | } | 190 | } | ||
| 144 | 191 | ||||
| @@ -152,12 +199,24 @@ func TestFormAllocations(t *testing.T) { | |||||
| 152 | m.duration = "1m30s" | 199 | m.duration = "1m30s" | ||
| 153 | require.NoError(t, m.parse()) | 200 | require.NoError(t, m.parse()) | ||
| 154 | assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form") | 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" | |||
| 155 | assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status") | 205 | assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status") | ||
| 156 | m.fps = "abc" | 206 | m.fps = "abc" | ||
| 157 | assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form") | 207 | assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form") | ||
| 158 | m.fps = "30" | 208 | m.fps = "30" | ||
| 159 | m.duration = "0" | 209 | m.duration = "0" | ||
| 160 | assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration") | 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) | |||
| 161 | } | 220 | } | ||
| 162 | 221 | ||||
| 163 | func BenchmarkFormParse(b *testing.B) { | 222 | func BenchmarkFormParse(b *testing.B) { | ||
M internal/adapters/gui/gui.go +42 -30
| @@ -30,8 +30,11 @@ func New() *Selector { return &Selector{} } | |||
| 30 | 30 | ||
| 31 | 31 | // Select blocks until the user accepts a selection (true), closes the window | |
| 32 | 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. | |
| 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. | |
| 35 | 38 | func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) { | |
| 36 | 39 | if err := ctx.Err(); err != nil { | |
| 37 | 40 | return ports.Selection{}, false, err | |
| @@ -59,22 +62,23 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 59 | 62 | return m.result(ctx, runErr) | |
| 60 | 63 | } | |
| 61 | 64 | ||
| 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. | |
| 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. | |
| 67 | 70 | 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 | |
| 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) | |
| 77 | 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 | |
| 78 | 82 | } | |
| 79 | 83 | if m.accepted { | |
| 80 | 84 | return m.sel, true, nil | |
| @@ -82,25 +86,33 @@ func (m *model) result(parent context.Context, runErr error) (ports.Selection, b | |||
| 82 | 86 | return ports.Selection{}, false, nil | |
| 83 | 87 | } | |
| 84 | 88 | ||
| 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 { | |
| 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 { | |
| 88 | 93 | return nil | |
| 89 | 94 | } | |
| 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) | |
| 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 | + | } | |
| 98 | 101 | } | |
| 102 | + | switch len(keep) { | |
| 103 | + | case 0: | |
| 104 | + | return nil | |
| 105 | + | case 1: | |
| 106 | + | return keep[0] | |
| 107 | + | } | |
| 108 | + | return errors.Join(keep...) | |
| 99 | 109 | } | |
| 100 | - | if len(keep) == 1 { | |
| 101 | - | return keep[0] | |
| 110 | + | for _, t := range targets { | |
| 111 | + | if err == t { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 102 | 114 | } | |
| 103 | - | return errors.Join(keep...) | |
| 115 | + | return err | |
| 104 | 116 | } | |
| 105 | 117 | ||
| 106 | 118 | // writeStyleSheet stores the embedded stylesheet in a private temporary file | |
| @@ -30,8 +30,11 @@ func New() *Selector { return &Selector{} } | |||||
| 30 | 30 | ||||
| 31 | // Select blocks until the user accepts a selection (true), closes the window | 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 | 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 | 33 | + | // destroyed by the panel's own Wayland session before an accepted selection is |
| 34 | - | // capture. Select must be called from one goroutine at a time. | 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. | |||
| 35 | func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) { | 38 | func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) { | ||
| 36 | if err := ctx.Err(); err != nil { | 39 | if err := ctx.Err(); err != nil { | ||
| 37 | return ports.Selection{}, false, err | 40 | return ports.Selection{}, false, err | ||
| @@ -59,22 +62,23 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 59 | return m.result(ctx, runErr) | 62 | return m.result(ctx, runErr) | ||
| 60 | } | 63 | } | ||
| 61 | 64 | ||||
| 62 | - | // result maps Run's outcome. A Canceled error caused by our own accept or | 65 | + | // result maps Run's outcome. The parent context is checked first: if it has |
| 63 | - | // dismiss is not a failure, but any other error (including one joined with it, | 66 | + | // ended, nothing is accepted and its error is returned, joined with any real |
| 64 | - | // such as a surface Close failure) is reported and rejects the selection. When | 67 | + | // Run error. Otherwise a Canceled error caused by our own accept or dismiss is |
| 65 | - | // the parent context ended, its error is always reported and nothing is | 68 | + | // not a failure, but any other error (including one joined with it, such as a |
| 66 | - | // accepted. | 69 | + | // surface Close failure) is reported and rejects the selection. |
| 67 | func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) { | 70 | func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) { | ||
| 68 | - | if runErr != nil { | 71 | + | if perr := parent.Err(); perr != nil { |
| 69 | - | if perr := parent.Err(); perr != nil { | 72 | + | if rest := dropErrors(runErr, context.Canceled, perr); rest != nil { |
| 70 | - | return ports.Selection{}, false, errors.Join(perr, dropCanceled(runErr)) | 73 | + | return ports.Selection{}, false, errors.Join(perr, rest) |
| 71 | - | } | |||
| 72 | - | if m.accepted || m.closed { | |||
| 73 | - | runErr = dropCanceled(runErr) | |||
| 74 | - | } | |||
| 75 | - | if runErr != nil { | |||
| 76 | - | return ports.Selection{}, false, runErr | |||
| 77 | } | 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 | |||
| 78 | } | 82 | } | ||
| 79 | if m.accepted { | 83 | if m.accepted { | ||
| 80 | return m.sel, true, nil | 84 | return m.sel, true, nil | ||
| @@ -82,25 +86,33 @@ func (m *model) result(parent context.Context, runErr error) (ports.Selection, b | |||||
| 82 | return ports.Selection{}, false, nil | 86 | return ports.Selection{}, false, nil | ||
| 83 | } | 87 | } | ||
| 84 | 88 | ||||
| 85 | - | // dropCanceled removes bare context.Canceled leaves from err and joined errors. | 89 | + | // dropErrors removes leaves equal to any target from err and from joined |
| 86 | - | func dropCanceled(err error) error { | 90 | + | // errors, keeping everything else. It returns nil if nothing real remains. |
| 87 | - | if err == nil || err == context.Canceled { | 91 | + | func dropErrors(err error, targets ...error) error { |
| 92 | + | if err == nil { | |||
| 88 | return nil | 93 | return nil | ||
| 89 | } | 94 | } | ||
| 90 | - | j, ok := err.(interface{ Unwrap() []error }) | 95 | + | if j, ok := err.(interface{ Unwrap() []error }); ok { |
| 91 | - | if !ok { | 96 | + | var keep []error |
| 92 | - | return err | 97 | + | for _, e := range j.Unwrap() { |
| 93 | - | } | 98 | + | if e = dropErrors(e, targets...); e != nil { |
| 94 | - | var keep []error | 99 | + | keep = append(keep, e) |
| 95 | - | for _, e := range j.Unwrap() { | 100 | + | } |
| 96 | - | if e = dropCanceled(e); e != nil { | |||
| 97 | - | keep = append(keep, e) | |||
| 98 | } | 101 | } | ||
| 102 | + | switch len(keep) { | |||
| 103 | + | case 0: | |||
| 104 | + | return nil | |||
| 105 | + | case 1: | |||
| 106 | + | return keep[0] | |||
| 107 | + | } | |||
| 108 | + | return errors.Join(keep...) | |||
| 99 | } | 109 | } | ||
| 100 | - | if len(keep) == 1 { | 110 | + | for _, t := range targets { |
| 101 | - | return keep[0] | 111 | + | if err == t { |
| 112 | + | return nil | |||
| 113 | + | } | |||
| 102 | } | 114 | } | ||
| 103 | - | return errors.Join(keep...) | 115 | + | return err |
| 104 | } | 116 | } | ||
| 105 | 117 | ||||
| 106 | // writeStyleSheet stores the embedded stylesheet in a private temporary file | 118 | // writeStyleSheet stores the embedded stylesheet in a private temporary file | ||
M internal/adapters/gui/gui_test.go +57 -8
| @@ -25,7 +25,7 @@ func TestNewModelLabelsAndDefaults(t *testing.T) { | |||
| 25 | 25 | assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot) | |
| 26 | 26 | assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec) | |
| 27 | 27 | assert.Equal(t, "30", m.fps) | |
| 28 | - | assert.Equal(t, "0", m.duration) | |
| 28 | + | assert.Equal(t, "10", m.duration) | |
| 29 | 29 | keys := map[string]bool{} | |
| 30 | 30 | for _, o := range m.outputs { | |
| 31 | 31 | assert.False(t, keys[o.key]) | |
| @@ -161,6 +161,7 @@ func TestResult(t *testing.T) { | |||
| 161 | 161 | {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr}, | |
| 162 | 162 | {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled}, | |
| 163 | 163 | {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled}, | |
| 164 | + | {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled}, | |
| 164 | 165 | {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled}, | |
| 165 | 166 | {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr}, | |
| 166 | 167 | } | |
| @@ -189,16 +190,59 @@ func TestResult(t *testing.T) { | |||
| 189 | 190 | } | |
| 190 | 191 | } | |
| 191 | 192 | ||
| 192 | - | func TestDropCanceled(t *testing.T) { | |
| 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) { | |
| 193 | 209 | 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))) | |
| 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)) | |
| 198 | 214 | 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"))) | |
| 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) | |
| 201 | 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) | |
| 202 | 246 | } | |
| 203 | 247 | ||
| 204 | 248 | func TestWriteStyleSheet(t *testing.T) { | |
| @@ -220,6 +264,11 @@ func TestStyleSheetUsesMonospace(t *testing.T) { | |||
| 220 | 264 | assert.Contains(t, styleSheet, "monospace") | |
| 221 | 265 | } | |
| 222 | 266 | ||
| 267 | + | func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) { | |
| 268 | + | assert.Contains(t, styleSheet, "overflow-y: auto") | |
| 269 | + | assert.NotContains(t, styleSheet, "separator") | |
| 270 | + | } | |
| 271 | + | ||
| 223 | 272 | func TestWriteStyleSheetFailure(t *testing.T) { | |
| 224 | 273 | t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) | |
| 225 | 274 | _, _, err := writeStyleSheet() | |
| @@ -25,7 +25,7 @@ func TestNewModelLabelsAndDefaults(t *testing.T) { | |||||
| 25 | assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot) | 25 | assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot) | ||
| 26 | assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec) | 26 | assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec) | ||
| 27 | assert.Equal(t, "30", m.fps) | 27 | assert.Equal(t, "30", m.fps) | ||
| 28 | - | assert.Equal(t, "0", m.duration) | 28 | + | assert.Equal(t, "10", m.duration) |
| 29 | keys := map[string]bool{} | 29 | keys := map[string]bool{} | ||
| 30 | for _, o := range m.outputs { | 30 | for _, o := range m.outputs { | ||
| 31 | assert.False(t, keys[o.key]) | 31 | assert.False(t, keys[o.key]) | ||
| @@ -161,6 +161,7 @@ func TestResult(t *testing.T) { | |||||
| 161 | {name: "setup failure", parent: context.Background(), runErr: 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}, | 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}, | 163 | {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled}, | ||
| 164 | + | {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled}, | |||
| 164 | {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled}, | 165 | {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 | {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr}, | ||
| 166 | } | 167 | } | ||
| @@ -189,16 +190,59 @@ func TestResult(t *testing.T) { | |||||
| 189 | } | 190 | } | ||
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| 192 | - | func TestDropCanceled(t *testing.T) { | 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) { | |||
| 193 | real := errors.New("real") | 209 | real := errors.New("real") | ||
| 194 | - | assert.NoError(t, dropCanceled(nil)) | 210 | + | assert.NoError(t, dropErrors(nil, context.Canceled)) |
| 195 | - | assert.NoError(t, dropCanceled(context.Canceled)) | 211 | + | assert.NoError(t, dropErrors(context.Canceled, context.Canceled)) |
| 196 | - | assert.NoError(t, dropCanceled(errors.Join(context.Canceled, context.Canceled))) | 212 | + | assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled)) |
| 197 | - | assert.Same(t, real, dropCanceled(errors.Join(context.Canceled, real))) | 213 | + | assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled)) |
| 198 | wrapped := fmt.Errorf("wrapped: %w", context.Canceled) | 214 | wrapped := fmt.Errorf("wrapped: %w", context.Canceled) | ||
| 199 | - | assert.Equal(t, wrapped, dropCanceled(wrapped), "wrapped cancellation is not ours") | 215 | + | assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours") |
| 200 | - | both := dropCanceled(errors.Join(real, errors.New("second"))) | 216 | + | both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled) |
| 201 | assert.ErrorIs(t, both, real) | 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) | |||
| 202 | } | 246 | } | ||
| 203 | 247 | ||||
| 204 | func TestWriteStyleSheet(t *testing.T) { | 248 | func TestWriteStyleSheet(t *testing.T) { | ||
| @@ -220,6 +264,11 @@ func TestStyleSheetUsesMonospace(t *testing.T) { | |||||
| 220 | assert.Contains(t, styleSheet, "monospace") | 264 | assert.Contains(t, styleSheet, "monospace") | ||
| 221 | } | 265 | } | ||
| 222 | 266 | ||||
| 267 | + | func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) { | |||
| 268 | + | assert.Contains(t, styleSheet, "overflow-y: auto") | |||
| 269 | + | assert.NotContains(t, styleSheet, "separator") | |||
| 270 | + | } | |||
| 271 | + | ||||
| 223 | func TestWriteStyleSheetFailure(t *testing.T) { | 272 | func TestWriteStyleSheetFailure(t *testing.T) { | ||
| 224 | t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) | 273 | t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) | ||
| 225 | _, _, err := writeStyleSheet() | 274 | _, _, err := writeStyleSheet() | ||
M internal/adapters/gui/model.go +38 -4
| @@ -2,7 +2,10 @@ package gui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io/fs" | |
| 5 | 7 | "os" | |
| 8 | + | "path/filepath" | |
| 6 | 9 | "strconv" | |
| 7 | 10 | "strings" | |
| 8 | 11 | "time" | |
| @@ -23,8 +26,16 @@ const ( | |||
| 23 | 26 | // support is limited to what the capture adapter offers. | |
| 24 | 27 | warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " + | |
| 25 | 28 | "of the current workspace. Off-screen sources and the cursor cannot be captured." | |
| 26 | - | recordNote = "duration 0 records until Ctrl+C in the terminal." | |
| 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" | |
| 27 | 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" | |
| 28 | 39 | ) | |
| 29 | 40 | ||
| 30 | 41 | // outputChoice is one precomputed radio entry. | |
| @@ -49,6 +60,12 @@ type model struct { | |||
| 49 | 60 | // notice is a submit-time problem shown until the path or mode changes. | |
| 50 | 61 | notice string | |
| 51 | 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 | + | ||
| 52 | 69 | sel ports.Selection | |
| 53 | 70 | accepted bool | |
| 54 | 71 | closed bool | |
| @@ -68,7 +85,7 @@ func newModel(outputs []ports.Output, now time.Time) (*model, error) { | |||
| 68 | 85 | defaultShot: "nefercap-" + stamp + ".png", | |
| 69 | 86 | defaultRec: "nefercap-" + stamp + ".mp4", | |
| 70 | 87 | fps: "30", | |
| 71 | - | duration: "0", | |
| 88 | + | duration: defaultDuration, | |
| 72 | 89 | } | |
| 73 | 90 | m.path = m.defaultShot | |
| 74 | 91 | var b strings.Builder | |
| @@ -161,8 +178,8 @@ func (m *model) submit() { | |||
| 161 | 178 | if err := m.parse(); err != nil { | |
| 162 | 179 | return | |
| 163 | 180 | } | |
| 164 | - | if _, err := os.Lstat(m.sel.Path); err == nil { | |
| 165 | - | m.notice = noticeExists | |
| 181 | + | if n := checkPath(m.sel.Path); n != "" { | |
| 182 | + | m.notice = n | |
| 166 | 183 | return | |
| 167 | 184 | } | |
| 168 | 185 | m.notice = "" | |
| @@ -170,6 +187,23 @@ func (m *model) submit() { | |||
| 170 | 187 | m.stop() | |
| 171 | 188 | } | |
| 172 | 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 | + | ||
| 173 | 207 | // dismiss closes the window without a selection. | |
| 174 | 208 | func (m *model) dismiss() { | |
| 175 | 209 | if m.accepted || m.closed { | |
| @@ -2,7 +2,10 @@ package gui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "io/fs" | |||
| 5 | "os" | 7 | "os" | ||
| 8 | + | "path/filepath" | |||
| 6 | "strconv" | 9 | "strconv" | ||
| 7 | "strings" | 10 | "strings" | ||
| 8 | "time" | 11 | "time" | ||
| @@ -23,8 +26,16 @@ const ( | |||||
| 23 | // support is limited to what the capture adapter offers. | 26 | // support is limited to what the capture adapter offers. | ||
| 24 | warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " + | 27 | 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." | 28 | "of the current workspace. Off-screen sources and the cursor cannot be captured." | ||
| 26 | - | recordNote = "duration 0 records until Ctrl+C in the terminal." | 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" | |||
| 27 | stampLayout = "20060102-150405" | 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" | |||
| 28 | ) | 39 | ) | ||
| 29 | 40 | ||||
| 30 | // outputChoice is one precomputed radio entry. | 41 | // outputChoice is one precomputed radio entry. | ||
| @@ -49,6 +60,12 @@ type model struct { | |||||
| 49 | // notice is a submit-time problem shown until the path or mode changes. | 60 | // notice is a submit-time problem shown until the path or mode changes. | ||
| 50 | notice string | 61 | notice string | ||
| 51 | 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 | + | ||||
| 52 | sel ports.Selection | 69 | sel ports.Selection | ||
| 53 | accepted bool | 70 | accepted bool | ||
| 54 | closed bool | 71 | closed bool | ||
| @@ -68,7 +85,7 @@ func newModel(outputs []ports.Output, now time.Time) (*model, error) { | |||||
| 68 | defaultShot: "nefercap-" + stamp + ".png", | 85 | defaultShot: "nefercap-" + stamp + ".png", | ||
| 69 | defaultRec: "nefercap-" + stamp + ".mp4", | 86 | defaultRec: "nefercap-" + stamp + ".mp4", | ||
| 70 | fps: "30", | 87 | fps: "30", | ||
| 71 | - | duration: "0", | 88 | + | duration: defaultDuration, |
| 72 | } | 89 | } | ||
| 73 | m.path = m.defaultShot | 90 | m.path = m.defaultShot | ||
| 74 | var b strings.Builder | 91 | var b strings.Builder | ||
| @@ -161,8 +178,8 @@ func (m *model) submit() { | |||||
| 161 | if err := m.parse(); err != nil { | 178 | if err := m.parse(); err != nil { | ||
| 162 | return | 179 | return | ||
| 163 | } | 180 | } | ||
| 164 | - | if _, err := os.Lstat(m.sel.Path); err == nil { | 181 | + | if n := checkPath(m.sel.Path); n != "" { |
| 165 | - | m.notice = noticeExists | 182 | + | m.notice = n |
| 166 | return | 183 | return | ||
| 167 | } | 184 | } | ||
| 168 | m.notice = "" | 185 | m.notice = "" | ||
| @@ -170,6 +187,23 @@ func (m *model) submit() { | |||||
| 170 | m.stop() | 187 | m.stop() | ||
| 171 | } | 188 | } | ||
| 172 | 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 | + | ||||
| 173 | // dismiss closes the window without a selection. | 207 | // dismiss closes the window without a selection. | ||
| 174 | func (m *model) dismiss() { | 208 | func (m *model) dismiss() { | ||
| 175 | if m.accepted || m.closed { | 209 | if m.accepted || m.closed { | ||
M internal/adapters/gui/style.css +4 -1
| @@ -5,7 +5,11 @@ app { | |||
| 5 | 5 | background: #0d1014; color: #c9d1d9; | |
| 6 | 6 | font-family: monospace; font-size: 14px; line-height: 1.4; | |
| 7 | 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; | |
| 8 | 10 | } | |
| 11 | + | /* Children keep their natural height so overflow scrolls instead of squashing. */ | |
| 12 | + | heading, text, radio, row, column, button { flex-shrink: 0; } | |
| 9 | 13 | heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; } | |
| 10 | 14 | text.section { margin-top: 8px; color: #8b949e; } | |
| 11 | 15 | text.note { color: #8b949e; font-size: 12px; } | |
| @@ -18,7 +22,6 @@ column.group { | |||
| 18 | 22 | } | |
| 19 | 23 | row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; } | |
| 20 | 24 | row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; } | |
| 21 | - | separator { background: #2a313b; } | |
| 22 | 25 | radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; } | |
| 23 | 26 | radio:hover { color: #e6edf3; } | |
| 24 | 27 | radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; } | |
| @@ -5,7 +5,11 @@ app { | |||||
| 5 | background: #0d1014; color: #c9d1d9; | 5 | background: #0d1014; color: #c9d1d9; | ||
| 6 | font-family: monospace; font-size: 14px; line-height: 1.4; | 6 | font-family: monospace; font-size: 14px; line-height: 1.4; | ||
| 7 | accent-color: #4fd1a5; | 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; | |||
| 8 | } | 10 | } | ||
| 11 | + | /* Children keep their natural height so overflow scrolls instead of squashing. */ | |||
| 12 | + | heading, text, radio, row, column, button { flex-shrink: 0; } | |||
| 9 | heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; } | 13 | heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; } | ||
| 10 | text.section { margin-top: 8px; color: #8b949e; } | 14 | text.section { margin-top: 8px; color: #8b949e; } | ||
| 11 | text.note { color: #8b949e; font-size: 12px; } | 15 | text.note { color: #8b949e; font-size: 12px; } | ||
| @@ -18,7 +22,6 @@ column.group { | |||||
| 18 | } | 22 | } | ||
| 19 | row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; } | 23 | 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; } | 24 | 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; } | 25 | radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; } | ||
| 23 | radio:hover { color: #e6edf3; } | 26 | radio:hover { color: #e6edf3; } | ||
| 24 | radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; } | 27 | radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; } | ||
M internal/adapters/gui/view.go +21 -20
| @@ -26,7 +26,7 @@ var ( | |||
| 26 | 26 | ||
| 27 | 27 | fileField = newField("file", "file", "path of the new capture file") | |
| 28 | 28 | regionField = newField("region", "region", "X,Y,WxH (empty = full output)") | |
| 29 | - | fpsField = newField("fps", "fps", "1-240") | |
| 29 | + | fpsField = newField("fps", "fps", fpsPlaceholder) | |
| 30 | 30 | sizeField = newField("size", "resolution", "WxH (empty = source size)") | |
| 31 | 31 | durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C") | |
| 32 | 32 | ) | |
| @@ -72,8 +72,28 @@ func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bo | |||
| 72 | 72 | // alone; events mutate it while the frame is built. | |
| 73 | 73 | func view(f *nefergui.Frame, m *model) { | |
| 74 | 74 | root := f.Root(rootOpts...) | |
| 75 | + | submit := false | |
| 75 | 76 | root.Heading("NeferCap", titleOpts...) | |
| 76 | 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 | + | ||
| 77 | 97 | root.Text("output", outputTitle...) | |
| 78 | 98 | outs := root.Column(outputsGroup...) | |
| 79 | 99 | for i := range m.outputs { | |
| @@ -87,7 +107,6 @@ func view(f *nefergui.Frame, m *model) { | |||
| 87 | 107 | modes.Radio("record", modeRecord, &m.mode, recOpts...) | |
| 88 | 108 | m.modeChanged() | |
| 89 | 109 | ||
| 90 | - | submit := false | |
| 91 | 110 | root.Text("target", targetTitle...) | |
| 92 | 111 | target := root.Column(targetGroup...) | |
| 93 | 112 | changed, entered := fileField.show(target, &m.path) | |
| @@ -112,24 +131,6 @@ func view(f *nefergui.Frame, m *model) { | |||
| 112 | 131 | ||
| 113 | 132 | root.Text(warning, warningOpts...) | |
| 114 | 133 | ||
| 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 | 134 | if submit { | |
| 134 | 135 | m.submit() | |
| 135 | 136 | } | |
| @@ -26,7 +26,7 @@ var ( | |||||
| 26 | 26 | ||||
| 27 | fileField = newField("file", "file", "path of the new capture file") | 27 | fileField = newField("file", "file", "path of the new capture file") | ||
| 28 | regionField = newField("region", "region", "X,Y,WxH (empty = full output)") | 28 | regionField = newField("region", "region", "X,Y,WxH (empty = full output)") | ||
| 29 | - | fpsField = newField("fps", "fps", "1-240") | 29 | + | fpsField = newField("fps", "fps", fpsPlaceholder) |
| 30 | sizeField = newField("size", "resolution", "WxH (empty = source size)") | 30 | sizeField = newField("size", "resolution", "WxH (empty = source size)") | ||
| 31 | durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C") | 31 | durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C") | ||
| 32 | ) | 32 | ) | ||
| @@ -72,8 +72,28 @@ func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bo | |||||
| 72 | // alone; events mutate it while the frame is built. | 72 | // alone; events mutate it while the frame is built. | ||
| 73 | func view(f *nefergui.Frame, m *model) { | 73 | func view(f *nefergui.Frame, m *model) { | ||
| 74 | root := f.Root(rootOpts...) | 74 | root := f.Root(rootOpts...) | ||
| 75 | + | submit := false | |||
| 75 | root.Heading("NeferCap", titleOpts...) | 76 | root.Heading("NeferCap", titleOpts...) | ||
| 76 | 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 | + | ||||
| 77 | root.Text("output", outputTitle...) | 97 | root.Text("output", outputTitle...) | ||
| 78 | outs := root.Column(outputsGroup...) | 98 | outs := root.Column(outputsGroup...) | ||
| 79 | for i := range m.outputs { | 99 | for i := range m.outputs { | ||
| @@ -87,7 +107,6 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 87 | modes.Radio("record", modeRecord, &m.mode, recOpts...) | 107 | modes.Radio("record", modeRecord, &m.mode, recOpts...) | ||
| 88 | m.modeChanged() | 108 | m.modeChanged() | ||
| 89 | 109 | ||||
| 90 | - | submit := false | |||
| 91 | root.Text("target", targetTitle...) | 110 | root.Text("target", targetTitle...) | ||
| 92 | target := root.Column(targetGroup...) | 111 | target := root.Column(targetGroup...) | ||
| 93 | changed, entered := fileField.show(target, &m.path) | 112 | changed, entered := fileField.show(target, &m.path) | ||
| @@ -112,24 +131,6 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 112 | 131 | ||||
| 113 | root.Text(warning, warningOpts...) | 132 | root.Text(warning, warningOpts...) | ||
| 114 | 133 | ||||
| 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 | if submit { | ||
| 134 | m.submit() | 135 | m.submit() | ||
| 135 | } | 136 | } | ||