Merge pull request 'feat(flash): flash the captured area after a screenshot' (#9) from feat/shot-flash into main
Reviewed-on: https://git.bnema.dev/bnema/nefercap/pulls/9
6 files · +267 -3
M README.md +2 -0
| @@ -24,6 +24,8 @@ bind.cmd+p = spawn nefercap | |||
| 24 | 24 | ||
| 25 | 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. A badge at the top left shows the mode: `SHOT` or `REC`. | |
| 26 | 26 | ||
| 27 | + | After a screenshot, a green frame briefly flashes over the captured area. It is drawn after the capture, so it never appears in the image. | |
| 28 | + | ||
| 27 | 29 | While recording on NeferWL, a border marks the target and a small **REC / Stop** HUD shows the time. Neither appears in the video. Press the key again, or click Stop, to finish the file. Other compositors show no indicator: the selector says so, and the same key (or `nefercap stop`) finishes the file. | |
| 28 | 30 | ||
| 29 | 31 | | Input | Action | | |
| @@ -24,6 +24,8 @@ bind.cmd+p = spawn nefercap | |||||
| 24 | 24 | ||||
| 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. A badge at the top left shows the mode: `SHOT` or `REC`. | 25 | Press the key: a selector covers every monitor. Pick what to capture, press **Tab** to switch between screenshot and recording, then confirm. A badge at the top left shows the mode: `SHOT` or `REC`. | ||
| 26 | 26 | ||||
| 27 | + | After a screenshot, a green frame briefly flashes over the captured area. It is drawn after the capture, so it never appears in the image. | |||
| 28 | + | ||||
| 27 | While recording on NeferWL, a border marks the target and a small **REC / Stop** HUD shows the time. Neither appears in the video. Press the key again, or click Stop, to finish the file. Other compositors show no indicator: the selector says so, and the same key (or `nefercap stop`) finishes the file. | 29 | While recording on NeferWL, a border marks the target and a small **REC / Stop** HUD shows the time. Neither appears in the video. Press the key again, or click Stop, to finish the file. Other compositors show no indicator: the selector says so, and the same key (or `nefercap stop`) finishes the file. | ||
| 28 | 30 | ||||
| 29 | | Input | Action | | 31 | | Input | Action | | ||
A internal/adapters/flash/flash.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | // Package flash confirms a saved screenshot: a green frame covers the captured | |
| 2 | + | // area and fades out in about 300 ms. It is drawn only after the capture, so it | |
| 3 | + | // never appears in the image. The surface takes no keyboard focus and lets | |
| 4 | + | // every click through. | |
| 5 | + | package flash | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | _ "embed" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/neferclient" | |
| 16 | + | "github.com/bnema/nefergui" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/nefercap/internal/adapters/layerui" | |
| 19 | + | "github.com/bnema/nefercap/internal/adapters/uierrors" | |
| 20 | + | "github.com/bnema/nefercap/internal/adapters/uistyle" | |
| 21 | + | "github.com/bnema/nefercap/internal/ports" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | //go:embed style.css | |
| 25 | + | var styleSheet string | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | namespace = "nefercap-flash" | |
| 29 | + | step = 30 * time.Millisecond | |
| 30 | + | // Limit bounds the whole effect, setup included, so a slow compositor or | |
| 31 | + | // GPU never keeps the process alive. | |
| 32 | + | Limit = 2 * time.Second | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // stepClasses are the opacity steps of style.css, from opaque to faint. | |
| 36 | + | var stepClasses = [...]string{"s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"} | |
| 37 | + | ||
| 38 | + | var errClosed = errors.New("flash closed by the compositor") | |
| 39 | + | ||
| 40 | + | // Show flashes area on output and returns when the fade ends. The zero area is | |
| 41 | + | // the whole output. It returns nil when the fade completed or ctx ended. | |
| 42 | + | func Show(ctx context.Context, output ports.Output, area ports.Region) error { | |
| 43 | + | if output.Name == "" { | |
| 44 | + | return fmt.Errorf("flash: output %d has no name: %w", output.ID, ports.ErrOutputNotFound) | |
| 45 | + | } | |
| 46 | + | if err := show(ctx, output, area); err != nil { | |
| 47 | + | return fmt.Errorf("flash: output %s: %w", output.Name, err) | |
| 48 | + | } | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func show(ctx context.Context, output ports.Output, area ports.Region) error { | |
| 53 | + | sheet, remove, err := uistyle.Write(styleSheet, "nefercap-flash-*.css") | |
| 54 | + | if err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | defer remove() | |
| 58 | + | ||
| 59 | + | child, cancel := context.WithTimeout(ctx, Limit) | |
| 60 | + | wake := make(chan struct{}, 1) | |
| 61 | + | var wg sync.WaitGroup | |
| 62 | + | wg.Go(func() { ticker(child, wake) }) | |
| 63 | + | defer func() { cancel(); wg.Wait() }() | |
| 64 | + | ||
| 65 | + | m := &fade{done: cancel} | |
| 66 | + | runErr := layerui.Run(child, layerui.Config{ | |
| 67 | + | Layer: layerConfig(output.Name, area), | |
| 68 | + | Styles: sheet, | |
| 69 | + | Wake: wake, | |
| 70 | + | }, m, view) | |
| 71 | + | // The fade, the time limit or the parent ended the run on purpose; only | |
| 72 | + | // an end with neither is the compositor closing the surface. | |
| 73 | + | explained := m.ended || child.Err() != nil | |
| 74 | + | return uierrors.Ended(runErr, explained, ctx.Err(), errClosed) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // layerConfig covers area, or the whole output for the zero area, above every | |
| 78 | + | // window, without focus or pointer input. | |
| 79 | + | func layerConfig(output string, area ports.Region) neferclient.LayerConfig { | |
| 80 | + | c := neferclient.LayerConfig{ | |
| 81 | + | Output: output, | |
| 82 | + | Namespace: namespace, | |
| 83 | + | Level: neferclient.LayerOverlay, | |
| 84 | + | Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight, | |
| 85 | + | Keyboard: neferclient.KeyboardNone, | |
| 86 | + | ExclusiveZone: -1, | |
| 87 | + | InputRects: []neferclient.Rect{}, | |
| 88 | + | } | |
| 89 | + | if area.Width > 0 && area.Height > 0 { | |
| 90 | + | c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft | |
| 91 | + | c.Margin = [4]int32{int32(area.Y), 0, 0, int32(area.X)} | |
| 92 | + | c.Width, c.Height = int32(area.Width), int32(area.Height) | |
| 93 | + | } | |
| 94 | + | return c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // ticker sends a non-blocking wake token every step until ctx ends. | |
| 98 | + | func ticker(ctx context.Context, wake chan<- struct{}) { | |
| 99 | + | t := time.NewTicker(step) | |
| 100 | + | defer t.Stop() | |
| 101 | + | for { | |
| 102 | + | select { | |
| 103 | + | case <-ctx.Done(): | |
| 104 | + | return | |
| 105 | + | case <-t.C: | |
| 106 | + | select { | |
| 107 | + | case wake <- struct{}{}: | |
| 108 | + | default: | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // fade is the view model. It is used only on the layerui owner goroutine. | |
| 115 | + | type fade struct { | |
| 116 | + | start time.Time // first frame; zero until then | |
| 117 | + | ended bool | |
| 118 | + | done func() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // class returns the opacity class for now. The fade starts with the first | |
| 122 | + | // frame, so a slow setup does not eat into it. Past the last step it ends the | |
| 123 | + | // run and keeps the faintest class. | |
| 124 | + | func (f *fade) class(now time.Time) string { | |
| 125 | + | if f.start.IsZero() { | |
| 126 | + | f.start = now | |
| 127 | + | } | |
| 128 | + | i := int(now.Sub(f.start) / step) | |
| 129 | + | if i >= len(stepClasses) { | |
| 130 | + | if !f.ended { | |
| 131 | + | f.ended = true | |
| 132 | + | f.done() | |
| 133 | + | } | |
| 134 | + | i = len(stepClasses) - 1 | |
| 135 | + | } | |
| 136 | + | return stepClasses[max(i, 0)] | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func view(f *nefergui.Frame, m *fade) { | |
| 140 | + | f.Root().Box(nefergui.Key("flash"), nefergui.Class("flash"), nefergui.Class(m.class(time.Now()))) | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | // Package flash confirms a saved screenshot: a green frame covers the captured | |||
| 2 | + | // area and fades out in about 300 ms. It is drawn only after the capture, so it | |||
| 3 | + | // never appears in the image. The surface takes no keyboard focus and lets | |||
| 4 | + | // every click through. | |||
| 5 | + | package flash | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | _ "embed" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/neferclient" | |||
| 16 | + | "github.com/bnema/nefergui" | |||
| 17 | + | ||||
| 18 | + | "github.com/bnema/nefercap/internal/adapters/layerui" | |||
| 19 | + | "github.com/bnema/nefercap/internal/adapters/uierrors" | |||
| 20 | + | "github.com/bnema/nefercap/internal/adapters/uistyle" | |||
| 21 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | //go:embed style.css | |||
| 25 | + | var styleSheet string | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | namespace = "nefercap-flash" | |||
| 29 | + | step = 30 * time.Millisecond | |||
| 30 | + | // Limit bounds the whole effect, setup included, so a slow compositor or | |||
| 31 | + | // GPU never keeps the process alive. | |||
| 32 | + | Limit = 2 * time.Second | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // stepClasses are the opacity steps of style.css, from opaque to faint. | |||
| 36 | + | var stepClasses = [...]string{"s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"} | |||
| 37 | + | ||||
| 38 | + | var errClosed = errors.New("flash closed by the compositor") | |||
| 39 | + | ||||
| 40 | + | // Show flashes area on output and returns when the fade ends. The zero area is | |||
| 41 | + | // the whole output. It returns nil when the fade completed or ctx ended. | |||
| 42 | + | func Show(ctx context.Context, output ports.Output, area ports.Region) error { | |||
| 43 | + | if output.Name == "" { | |||
| 44 | + | return fmt.Errorf("flash: output %d has no name: %w", output.ID, ports.ErrOutputNotFound) | |||
| 45 | + | } | |||
| 46 | + | if err := show(ctx, output, area); err != nil { | |||
| 47 | + | return fmt.Errorf("flash: output %s: %w", output.Name, err) | |||
| 48 | + | } | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func show(ctx context.Context, output ports.Output, area ports.Region) error { | |||
| 53 | + | sheet, remove, err := uistyle.Write(styleSheet, "nefercap-flash-*.css") | |||
| 54 | + | if err != nil { | |||
| 55 | + | return err | |||
| 56 | + | } | |||
| 57 | + | defer remove() | |||
| 58 | + | ||||
| 59 | + | child, cancel := context.WithTimeout(ctx, Limit) | |||
| 60 | + | wake := make(chan struct{}, 1) | |||
| 61 | + | var wg sync.WaitGroup | |||
| 62 | + | wg.Go(func() { ticker(child, wake) }) | |||
| 63 | + | defer func() { cancel(); wg.Wait() }() | |||
| 64 | + | ||||
| 65 | + | m := &fade{done: cancel} | |||
| 66 | + | runErr := layerui.Run(child, layerui.Config{ | |||
| 67 | + | Layer: layerConfig(output.Name, area), | |||
| 68 | + | Styles: sheet, | |||
| 69 | + | Wake: wake, | |||
| 70 | + | }, m, view) | |||
| 71 | + | // The fade, the time limit or the parent ended the run on purpose; only | |||
| 72 | + | // an end with neither is the compositor closing the surface. | |||
| 73 | + | explained := m.ended || child.Err() != nil | |||
| 74 | + | return uierrors.Ended(runErr, explained, ctx.Err(), errClosed) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // layerConfig covers area, or the whole output for the zero area, above every | |||
| 78 | + | // window, without focus or pointer input. | |||
| 79 | + | func layerConfig(output string, area ports.Region) neferclient.LayerConfig { | |||
| 80 | + | c := neferclient.LayerConfig{ | |||
| 81 | + | Output: output, | |||
| 82 | + | Namespace: namespace, | |||
| 83 | + | Level: neferclient.LayerOverlay, | |||
| 84 | + | Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight, | |||
| 85 | + | Keyboard: neferclient.KeyboardNone, | |||
| 86 | + | ExclusiveZone: -1, | |||
| 87 | + | InputRects: []neferclient.Rect{}, | |||
| 88 | + | } | |||
| 89 | + | if area.Width > 0 && area.Height > 0 { | |||
| 90 | + | c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft | |||
| 91 | + | c.Margin = [4]int32{int32(area.Y), 0, 0, int32(area.X)} | |||
| 92 | + | c.Width, c.Height = int32(area.Width), int32(area.Height) | |||
| 93 | + | } | |||
| 94 | + | return c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // ticker sends a non-blocking wake token every step until ctx ends. | |||
| 98 | + | func ticker(ctx context.Context, wake chan<- struct{}) { | |||
| 99 | + | t := time.NewTicker(step) | |||
| 100 | + | defer t.Stop() | |||
| 101 | + | for { | |||
| 102 | + | select { | |||
| 103 | + | case <-ctx.Done(): | |||
| 104 | + | return | |||
| 105 | + | case <-t.C: | |||
| 106 | + | select { | |||
| 107 | + | case wake <- struct{}{}: | |||
| 108 | + | default: | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // fade is the view model. It is used only on the layerui owner goroutine. | |||
| 115 | + | type fade struct { | |||
| 116 | + | start time.Time // first frame; zero until then | |||
| 117 | + | ended bool | |||
| 118 | + | done func() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // class returns the opacity class for now. The fade starts with the first | |||
| 122 | + | // frame, so a slow setup does not eat into it. Past the last step it ends the | |||
| 123 | + | // run and keeps the faintest class. | |||
| 124 | + | func (f *fade) class(now time.Time) string { | |||
| 125 | + | if f.start.IsZero() { | |||
| 126 | + | f.start = now | |||
| 127 | + | } | |||
| 128 | + | i := int(now.Sub(f.start) / step) | |||
| 129 | + | if i >= len(stepClasses) { | |||
| 130 | + | if !f.ended { | |||
| 131 | + | f.ended = true | |||
| 132 | + | f.done() | |||
| 133 | + | } | |||
| 134 | + | i = len(stepClasses) - 1 | |||
| 135 | + | } | |||
| 136 | + | return stepClasses[max(i, 0)] | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func view(f *nefergui.Frame, m *fade) { | |||
| 140 | + | f.Root().Box(nefergui.Key("flash"), nefergui.Class("flash"), nefergui.Class(m.class(time.Now()))) | |||
| 141 | + | } | |||
A internal/adapters/flash/flash_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package flash | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferclient" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefercap/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestLayerConfigWholeOutput(t *testing.T) { | |
| 17 | + | c := layerConfig("DP-1", ports.Region{}) | |
| 18 | + | assert.Equal(t, "DP-1", c.Output) | |
| 19 | + | assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors) | |
| 20 | + | assert.Equal(t, [4]int32{}, c.Margin) | |
| 21 | + | assert.Zero(t, c.Width) | |
| 22 | + | assert.Zero(t, c.Height) | |
| 23 | + | assert.Equal(t, neferclient.KeyboardNone, c.Keyboard) | |
| 24 | + | assert.NotNil(t, c.InputRects, "an empty non-nil input region lets clicks through") | |
| 25 | + | assert.Empty(t, c.InputRects) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestLayerConfigRegion(t *testing.T) { | |
| 29 | + | c := layerConfig("DP-1", ports.Region{X: 10, Y: 20, Width: 300, Height: 200}) | |
| 30 | + | assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorLeft, c.Anchors) | |
| 31 | + | assert.Equal(t, [4]int32{20, 0, 0, 10}, c.Margin) | |
| 32 | + | assert.Equal(t, int32(300), c.Width) | |
| 33 | + | assert.Equal(t, int32(200), c.Height) | |
| 34 | + | assert.NotNil(t, c.InputRects) | |
| 35 | + | assert.Empty(t, c.InputRects) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestFadeSteps(t *testing.T) { | |
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 40 | + | defer cancel() | |
| 41 | + | f := &fade{done: cancel} | |
| 42 | + | start := time.Unix(100, 0) | |
| 43 | + | assert.Equal(t, "s0", f.class(start), "the fade starts with the first frame") | |
| 44 | + | assert.Equal(t, "s0", f.class(start.Add(step-time.Millisecond))) | |
| 45 | + | assert.Equal(t, "s1", f.class(start.Add(step))) | |
| 46 | + | assert.Equal(t, "s9", f.class(start.Add(9*step))) | |
| 47 | + | assert.NoError(t, ctx.Err()) | |
| 48 | + | assert.False(t, f.ended) | |
| 49 | + | assert.Equal(t, "s9", f.class(start.Add(10*step)), "past the end the faintest step stays") | |
| 50 | + | assert.Equal(t, "s9", f.class(start.Add(time.Hour))) | |
| 51 | + | assert.ErrorIs(t, ctx.Err(), context.Canceled, "the fade ends the run") | |
| 52 | + | assert.True(t, f.ended) | |
| 53 | + | assert.Equal(t, "s0", (&fade{start: start, done: cancel}).class(start.Add(-time.Second)), "a reading before start never underflows") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestShowRequiresOutputName(t *testing.T) { | |
| 57 | + | err := Show(context.Background(), ports.Output{ID: 3}, ports.Region{}) | |
| 58 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // TestSmokeShow shows the real flash. It needs a Wayland compositor with | |
| 62 | + | // layer-shell and Vulkan, so it runs only when NEFERCAP_FLASH_OUTPUT names the | |
| 63 | + | // wl_output to show it on (for example HEADLESS-1 on a NeferWL headless | |
| 64 | + | // compositor). | |
| 65 | + | func TestSmokeShow(t *testing.T) { | |
| 66 | + | name := os.Getenv("NEFERCAP_FLASH_OUTPUT") | |
| 67 | + | if name == "" { | |
| 68 | + | t.Skip("set NEFERCAP_FLASH_OUTPUT to the output name on a Wayland session") | |
| 69 | + | } | |
| 70 | + | began := time.Now() | |
| 71 | + | require.NoError(t, Show(context.Background(), ports.Output{ID: 1, Name: name}, ports.Region{X: 40, Y: 40, Width: 320, Height: 200})) | |
| 72 | + | assert.Less(t, time.Since(began), Limit+time.Second) | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package flash | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferclient" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestLayerConfigWholeOutput(t *testing.T) { | |||
| 17 | + | c := layerConfig("DP-1", ports.Region{}) | |||
| 18 | + | assert.Equal(t, "DP-1", c.Output) | |||
| 19 | + | assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors) | |||
| 20 | + | assert.Equal(t, [4]int32{}, c.Margin) | |||
| 21 | + | assert.Zero(t, c.Width) | |||
| 22 | + | assert.Zero(t, c.Height) | |||
| 23 | + | assert.Equal(t, neferclient.KeyboardNone, c.Keyboard) | |||
| 24 | + | assert.NotNil(t, c.InputRects, "an empty non-nil input region lets clicks through") | |||
| 25 | + | assert.Empty(t, c.InputRects) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestLayerConfigRegion(t *testing.T) { | |||
| 29 | + | c := layerConfig("DP-1", ports.Region{X: 10, Y: 20, Width: 300, Height: 200}) | |||
| 30 | + | assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorLeft, c.Anchors) | |||
| 31 | + | assert.Equal(t, [4]int32{20, 0, 0, 10}, c.Margin) | |||
| 32 | + | assert.Equal(t, int32(300), c.Width) | |||
| 33 | + | assert.Equal(t, int32(200), c.Height) | |||
| 34 | + | assert.NotNil(t, c.InputRects) | |||
| 35 | + | assert.Empty(t, c.InputRects) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestFadeSteps(t *testing.T) { | |||
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 40 | + | defer cancel() | |||
| 41 | + | f := &fade{done: cancel} | |||
| 42 | + | start := time.Unix(100, 0) | |||
| 43 | + | assert.Equal(t, "s0", f.class(start), "the fade starts with the first frame") | |||
| 44 | + | assert.Equal(t, "s0", f.class(start.Add(step-time.Millisecond))) | |||
| 45 | + | assert.Equal(t, "s1", f.class(start.Add(step))) | |||
| 46 | + | assert.Equal(t, "s9", f.class(start.Add(9*step))) | |||
| 47 | + | assert.NoError(t, ctx.Err()) | |||
| 48 | + | assert.False(t, f.ended) | |||
| 49 | + | assert.Equal(t, "s9", f.class(start.Add(10*step)), "past the end the faintest step stays") | |||
| 50 | + | assert.Equal(t, "s9", f.class(start.Add(time.Hour))) | |||
| 51 | + | assert.ErrorIs(t, ctx.Err(), context.Canceled, "the fade ends the run") | |||
| 52 | + | assert.True(t, f.ended) | |||
| 53 | + | assert.Equal(t, "s0", (&fade{start: start, done: cancel}).class(start.Add(-time.Second)), "a reading before start never underflows") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestShowRequiresOutputName(t *testing.T) { | |||
| 57 | + | err := Show(context.Background(), ports.Output{ID: 3}, ports.Region{}) | |||
| 58 | + | require.ErrorIs(t, err, ports.ErrOutputNotFound) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // TestSmokeShow shows the real flash. It needs a Wayland compositor with | |||
| 62 | + | // layer-shell and Vulkan, so it runs only when NEFERCAP_FLASH_OUTPUT names the | |||
| 63 | + | // wl_output to show it on (for example HEADLESS-1 on a NeferWL headless | |||
| 64 | + | // compositor). | |||
| 65 | + | func TestSmokeShow(t *testing.T) { | |||
| 66 | + | name := os.Getenv("NEFERCAP_FLASH_OUTPUT") | |||
| 67 | + | if name == "" { | |||
| 68 | + | t.Skip("set NEFERCAP_FLASH_OUTPUT to the output name on a Wayland session") | |||
| 69 | + | } | |||
| 70 | + | began := time.Now() | |||
| 71 | + | require.NoError(t, Show(context.Background(), ports.Output{ID: 1, Name: name}, ports.Region{X: 40, Y: 40, Width: 320, Height: 200})) | |||
| 72 | + | assert.Less(t, time.Since(began), Limit+time.Second) | |||
| 73 | + | } | |||
A internal/adapters/flash/style.css +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | /* Screenshot success: a green frame over the captured area that fades out. */ | |
| 2 | + | app { width: 100%; height: 100%; background-color: transparent; } | |
| 3 | + | box.flash { | |
| 4 | + | width: 100%; height: 100%; box-sizing: border-box; | |
| 5 | + | border: 4px solid #4fd1a5; background-color: rgba(79, 209, 165, 0.12); | |
| 6 | + | } | |
| 7 | + | box.s0 { opacity: 1; } | |
| 8 | + | box.s1 { opacity: 0.9; } | |
| 9 | + | box.s2 { opacity: 0.8; } | |
| 10 | + | box.s3 { opacity: 0.7; } | |
| 11 | + | box.s4 { opacity: 0.6; } | |
| 12 | + | box.s5 { opacity: 0.5; } | |
| 13 | + | box.s6 { opacity: 0.4; } | |
| 14 | + | box.s7 { opacity: 0.3; } | |
| 15 | + | box.s8 { opacity: 0.2; } | |
| 16 | + | box.s9 { opacity: 0.1; } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | /* Screenshot success: a green frame over the captured area that fades out. */ | |||
| 2 | + | app { width: 100%; height: 100%; background-color: transparent; } | |||
| 3 | + | box.flash { | |||
| 4 | + | width: 100%; height: 100%; box-sizing: border-box; | |||
| 5 | + | border: 4px solid #4fd1a5; background-color: rgba(79, 209, 165, 0.12); | |||
| 6 | + | } | |||
| 7 | + | box.s0 { opacity: 1; } | |||
| 8 | + | box.s1 { opacity: 0.9; } | |||
| 9 | + | box.s2 { opacity: 0.8; } | |||
| 10 | + | box.s3 { opacity: 0.7; } | |||
| 11 | + | box.s4 { opacity: 0.6; } | |||
| 12 | + | box.s5 { opacity: 0.5; } | |||
| 13 | + | box.s6 { opacity: 0.4; } | |||
| 14 | + | box.s7 { opacity: 0.3; } | |||
| 15 | + | box.s8 { opacity: 0.2; } | |||
| 16 | + | box.s9 { opacity: 0.1; } | |||
M internal/app/app.go +34 -2
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 12 | 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | |
| 13 | 13 | "github.com/bnema/nefercap/internal/adapters/config" | |
| 14 | + | "github.com/bnema/nefercap/internal/adapters/flash" | |
| 14 | 15 | "github.com/bnema/nefercap/internal/adapters/png" | |
| 15 | 16 | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 16 | 17 | "github.com/bnema/nefercap/internal/core" | |
| @@ -50,7 +51,8 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 50 | 51 | return nil | |
| 51 | 52 | } | |
| 52 | 53 | var selection ports.Selection | |
| 53 | - | if options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne { | |
| 54 | + | interactive := options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne | |
| 55 | + | if interactive { | |
| 54 | 56 | // Fail on a bad config before anything is shown. | |
| 55 | 57 | settings, loadErr := config.Load() | |
| 56 | 58 | if loadErr != nil { | |
| @@ -102,7 +104,37 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 102 | 104 | if saved && err == nil { | |
| 103 | 105 | log.Info().Msg("capture complete") | |
| 104 | 106 | } | |
| 105 | - | return finishCapture(output, selection.Path, saved, err) | |
| 107 | + | err = finishCapture(output, selection.Path, saved, err) | |
| 108 | + | if err == nil && interactive && selection.Mode == ports.Screenshot { | |
| 109 | + | confirmScreenshot(ctx, source, outputs, selection.Target) | |
| 110 | + | } | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // confirmScreenshot flashes the captured area once the screenshot is done. The | |
| 115 | + | // screenshot already succeeded, so a failed flash is only logged. One budget | |
| 116 | + | // bounds the workspace lookup and the flash, so a silent compositor cannot | |
| 117 | + | // keep the process alive. | |
| 118 | + | func confirmScreenshot(ctx context.Context, source *wayland.Source, outputs []ports.Output, target ports.Target) { | |
| 119 | + | ctx, cancel := context.WithTimeout(ctx, flash.Limit) | |
| 120 | + | defer cancel() | |
| 121 | + | for _, o := range outputs { | |
| 122 | + | if o.ID != target.OutputID { | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | area := target.Region | |
| 126 | + | if target.WorkspaceID != 0 { | |
| 127 | + | area = recordedFrame(ctx, source, target) | |
| 128 | + | if ctx.Err() != nil { | |
| 129 | + | return // the budget went on the lookup | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | if err := flash.Show(ctx, o, area); err != nil { | |
| 133 | + | log := logging.For(ctx, "app") | |
| 134 | + | log.Warn().Err(err).Msg("screenshot confirmation not shown") | |
| 135 | + | } | |
| 136 | + | return | |
| 137 | + | } | |
| 106 | 138 | } | |
| 107 | 139 | ||
| 108 | 140 | // finishCapture prints the path of a file that exists, whatever error the | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/nefercap/internal/adapters/cli" | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | ||
| 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | 12 | "github.com/bnema/nefercap/internal/adapters/clipboard" | ||
| 13 | "github.com/bnema/nefercap/internal/adapters/config" | 13 | "github.com/bnema/nefercap/internal/adapters/config" | ||
| 14 | + | "github.com/bnema/nefercap/internal/adapters/flash" | |||
| 14 | "github.com/bnema/nefercap/internal/adapters/png" | 15 | "github.com/bnema/nefercap/internal/adapters/png" | ||
| 15 | "github.com/bnema/nefercap/internal/adapters/wayland" | 16 | "github.com/bnema/nefercap/internal/adapters/wayland" | ||
| 16 | "github.com/bnema/nefercap/internal/core" | 17 | "github.com/bnema/nefercap/internal/core" | ||
| @@ -50,7 +51,8 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 50 | return nil | 51 | return nil | ||
| 51 | } | 52 | } | ||
| 52 | var selection ports.Selection | 53 | var selection ports.Selection | ||
| 53 | - | if options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne { | 54 | + | interactive := options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne |
| 55 | + | if interactive { | |||
| 54 | // Fail on a bad config before anything is shown. | 56 | // Fail on a bad config before anything is shown. | ||
| 55 | settings, loadErr := config.Load() | 57 | settings, loadErr := config.Load() | ||
| 56 | if loadErr != nil { | 58 | if loadErr != nil { | ||
| @@ -102,7 +104,37 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 102 | if saved && err == nil { | 104 | if saved && err == nil { | ||
| 103 | log.Info().Msg("capture complete") | 105 | log.Info().Msg("capture complete") | ||
| 104 | } | 106 | } | ||
| 105 | - | return finishCapture(output, selection.Path, saved, err) | 107 | + | err = finishCapture(output, selection.Path, saved, err) |
| 108 | + | if err == nil && interactive && selection.Mode == ports.Screenshot { | |||
| 109 | + | confirmScreenshot(ctx, source, outputs, selection.Target) | |||
| 110 | + | } | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // confirmScreenshot flashes the captured area once the screenshot is done. The | |||
| 115 | + | // screenshot already succeeded, so a failed flash is only logged. One budget | |||
| 116 | + | // bounds the workspace lookup and the flash, so a silent compositor cannot | |||
| 117 | + | // keep the process alive. | |||
| 118 | + | func confirmScreenshot(ctx context.Context, source *wayland.Source, outputs []ports.Output, target ports.Target) { | |||
| 119 | + | ctx, cancel := context.WithTimeout(ctx, flash.Limit) | |||
| 120 | + | defer cancel() | |||
| 121 | + | for _, o := range outputs { | |||
| 122 | + | if o.ID != target.OutputID { | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | area := target.Region | |||
| 126 | + | if target.WorkspaceID != 0 { | |||
| 127 | + | area = recordedFrame(ctx, source, target) | |||
| 128 | + | if ctx.Err() != nil { | |||
| 129 | + | return // the budget went on the lookup | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | if err := flash.Show(ctx, o, area); err != nil { | |||
| 133 | + | log := logging.For(ctx, "app") | |||
| 134 | + | log.Warn().Err(err).Msg("screenshot confirmation not shown") | |||
| 135 | + | } | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 106 | } | 138 | } | ||
| 107 | 139 | ||||
| 108 | // finishCapture prints the path of a file that exists, whatever error the | 140 | // finishCapture prints the path of a file that exists, whatever error the | ||
M internal/app/record_interactive.go +1 -1
| @@ -188,7 +188,7 @@ func recordedFrame(ctx context.Context, source *wayland.Source, t ports.Target) | |||
| 188 | 188 | return ports.Region{} | |
| 189 | 189 | } | |
| 190 | 190 | log := logging.For(ctx, "app") | |
| 191 | - | log.Warn().Err(err).Msg("workspace frame unavailable: HUD on the whole output") | |
| 191 | + | log.Warn().Err(err).Msg("workspace frame unavailable: using the whole output") | |
| 192 | 192 | return ports.Region{} | |
| 193 | 193 | } | |
| 194 | 194 | for _, w := range list { | |
| @@ -188,7 +188,7 @@ func recordedFrame(ctx context.Context, source *wayland.Source, t ports.Target) | |||||
| 188 | return ports.Region{} | 188 | return ports.Region{} | ||
| 189 | } | 189 | } | ||
| 190 | log := logging.For(ctx, "app") | 190 | log := logging.For(ctx, "app") | ||
| 191 | - | log.Warn().Err(err).Msg("workspace frame unavailable: HUD on the whole output") | 191 | + | log.Warn().Err(err).Msg("workspace frame unavailable: using the whole output") |
| 192 | return ports.Region{} | 192 | return ports.Region{} | ||
| 193 | } | 193 | } | ||
| 194 | for _, w := range list { | 194 | for _, w := range list { | ||