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