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.
5package flash
6
7import (
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
25var styleSheet string
26
27const (
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.
36var stepClasses = [...]string{"s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"}
37
38var 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.
42func 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
52func 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.
79func 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.
98func 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.
115type 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.
124func (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
139func view(f *nefergui.Frame, m *fade) {
140 f.Root().Box(nefergui.Key("flash"), nefergui.Class("flash"), nefergui.Class(m.class(time.Now())))
141}