detached 76bcd84

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
commit
76bcd84b54711546e443e84dac490ccf1abac276
author
bnema · · 2026-10-15 11:56 UTC
merge of
15e034a + 6a15f35 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.4.0
6 files · +267 -3
M README.md +2 -0
@@ -24,6 +24,8 @@ bind.cmd+p = spawn nefercap
2424
2525 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`.
2626
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+
2729 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.
2830
2931 | 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 (
1111 "github.com/bnema/nefercap/internal/adapters/cli"
1212 "github.com/bnema/nefercap/internal/adapters/clipboard"
1313 "github.com/bnema/nefercap/internal/adapters/config"
14+ "github.com/bnema/nefercap/internal/adapters/flash"
1415 "github.com/bnema/nefercap/internal/adapters/png"
1516 "github.com/bnema/nefercap/internal/adapters/wayland"
1617 "github.com/bnema/nefercap/internal/core"
@@ -50,7 +51,8 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
5051 return nil
5152 }
5253 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 {
5456 // Fail on a bad config before anything is shown.
5557 settings, loadErr := config.Load()
5658 if loadErr != nil {
@@ -102,7 +104,37 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
102104 if saved && err == nil {
103105 log.Info().Msg("capture complete")
104106 }
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+ }
106138 }
107139
108140 // 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 nil51 return nil
51 }52 }
52 var selection ports.Selection53 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 the140 // 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)
188188 return ports.Region{}
189189 }
190190 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")
192192 return ports.Region{}
193193 }
194194 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close