feat(selection): show mode and target chips with key toasts and a border pulse
Chips at the top left show SHOT/REC and R Region/M Monitor/W Workspace; the active ones and the selection border take the mode color (cyan for screenshots, amber for recordings). Every R, M, W and Tab shows a short centered toast and pulses the border; a per-overlay animator wakes the view only while an effect runs. W without a workspace on the output falls back to the monitor and shows a warning toast.
- commit
- e2eda52f1116fc65ee9b03e703e513bcfc4bcbd6
- author
- brice · · 2026-10-17 17:39 UTC
- parent
- 76bcd84
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +693 -43
M Makefile +1 -1
| @@ -48,7 +48,7 @@ arch: | |||
| 48 | 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 49 | 49 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 50 | 50 | # pass vacuously when a frame-path test is removed or renamed. | |
| 51 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | |
| 51 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | |
| 52 | 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | |
| 53 | 53 | perf_empty := | |
| 54 | 54 | perf_space := $(perf_empty) $(perf_empty) | |
| @@ -48,7 +48,7 @@ arch: | |||||
| 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | 48 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | ||
| 49 | # the packages holding them. Every named test must exist, so the guard cannot | 49 | # the packages holding them. Every named test must exist, so the guard cannot | ||
| 50 | # pass vacuously when a frame-path test is removed or renamed. | 50 | # pass vacuously when a frame-path test is removed or renamed. | ||
| 51 | - | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations | 51 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestMonitorGridAllocations TestMonitorGridPhysicalAllocations TestGridAllocations TestGridModeAllocations TestSelectorAllocations TestModeToggleAllocations TestEffectsAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations TestCaptureAllocationsCancellable TestCropAllocations |
| 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | 52 | PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland | ||
| 53 | perf_empty := | 53 | perf_empty := | ||
| 54 | perf_space := $(perf_empty) $(perf_empty) | 54 | perf_space := $(perf_empty) $(perf_empty) | ||
M README.md +4 -2
| @@ -22,7 +22,9 @@ Bind `nefercap` to a key in your compositor. For NeferWL: | |||
| 22 | 22 | bind.cmd+p = spawn nefercap | |
| 23 | 23 | ``` | |
| 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. | |
| 26 | + | ||
| 27 | + | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, amber for recordings. Each key press also shows a short toast in the center and pulses the border. | |
| 26 | 28 | ||
| 27 | 29 | 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 | 30 | ||
| @@ -32,7 +34,7 @@ While recording on NeferWL, a border marks the target and a small **REC / Stop** | |||
| 32 | 34 | | --- | --- | | |
| 33 | 35 | | Drag | Capture a rectangle | | |
| 34 | 36 | | Click | Capture the monitor | | |
| 35 | - | | `W`, then Enter | Capture the current workspace | | |
| 37 | + | | `W`, then Enter | Capture the current workspace, or the monitor (with a warning) when there is none | | |
| 36 | 38 | | `M`, then Enter | Capture the monitor | | |
| 37 | 39 | | `1`–`9` | Pick that monitor | | |
| 38 | 40 | | `R` | Back to rectangle selection | | |
| @@ -22,7 +22,9 @@ Bind `nefercap` to a key in your compositor. For NeferWL: | |||||
| 22 | bind.cmd+p = spawn nefercap | 22 | bind.cmd+p = spawn nefercap | ||
| 23 | ``` | 23 | ``` | ||
| 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. |
| 26 | + | ||||
| 27 | + | Chips at the top left show the mode (`SHOT` or `REC`) and the target (`R Region`, `M Monitor`, `W Workspace`). The active ones and the selection border take the mode color: cyan for screenshots, amber for recordings. Each key press also shows a short toast in the center and pulses the border. | |||
| 26 | 28 | ||||
| 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. | 29 | 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 | 30 | ||||
| @@ -32,7 +34,7 @@ While recording on NeferWL, a border marks the target and a small **REC / Stop** | |||||
| 32 | | --- | --- | | 34 | | --- | --- | | ||
| 33 | | Drag | Capture a rectangle | | 35 | | Drag | Capture a rectangle | | ||
| 34 | | Click | Capture the monitor | | 36 | | Click | Capture the monitor | | ||
| 35 | - | | `W`, then Enter | Capture the current workspace | | 37 | + | | `W`, then Enter | Capture the current workspace, or the monitor (with a warning) when there is none | |
| 36 | | `M`, then Enter | Capture the monitor | | 38 | | `M`, then Enter | Capture the monitor | | ||
| 37 | | `1`–`9` | Pick that monitor | | 39 | | `1`–`9` | Pick that monitor | | ||
| 38 | | `R` | Back to rectangle selection | | 40 | | `R` | Back to rectangle selection | | ||
M internal/adapters/cli/cli.go +2 -1
| @@ -75,7 +75,8 @@ Selector keys and mouse: | |||
| 75 | 75 | click capture the whole monitor; after w, the current workspace | |
| 76 | 76 | r back to region selection | |
| 77 | 77 | m choose the whole monitor, Enter confirms | |
| 78 | - | w choose the current workspace when known, click or Enter confirms | |
| 78 | + | w choose the current workspace, or the monitor with a warning when | |
| 79 | + | none is known; click or Enter confirms | |
| 79 | 80 | 1..9 pick that whole monitor from any overlay | |
| 80 | 81 | g toggle full-monitor center and thirds guides (on by default) | |
| 81 | 82 | Tab switch shot <-> rec (all-in-one only) | |
| @@ -75,7 +75,8 @@ Selector keys and mouse: | |||||
| 75 | click capture the whole monitor; after w, the current workspace | 75 | click capture the whole monitor; after w, the current workspace | ||
| 76 | r back to region selection | 76 | r back to region selection | ||
| 77 | m choose the whole monitor, Enter confirms | 77 | m choose the whole monitor, Enter confirms | ||
| 78 | - | w choose the current workspace when known, click or Enter confirms | 78 | + | w choose the current workspace, or the monitor with a warning when |
| 79 | + | none is known; click or Enter confirms | |||
| 79 | 1..9 pick that whole monitor from any overlay | 80 | 1..9 pick that whole monitor from any overlay | ||
| 80 | g toggle full-monitor center and thirds guides (on by default) | 81 | g toggle full-monitor center and thirds guides (on by default) | ||
| 81 | Tab switch shot <-> rec (all-in-one only) | 82 | Tab switch shot <-> rec (all-in-one only) | ||
A internal/adapters/selection/fx.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package selection | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "math" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/nefercap/internal/core" | |
| 10 | + | "github.com/bnema/nefercap/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Short effects confirm a key press: a toast names the new mode or target and | |
| 14 | + | // the selection border pulses. They only exist while the selector is open, so | |
| 15 | + | // they never reach a capture. | |
| 16 | + | const ( | |
| 17 | + | fxStep = 30 * time.Millisecond | |
| 18 | + | toastFade = 9 * fxStep // o1..o9 after the hold | |
| 19 | + | modeHold = 600 * time.Millisecond | |
| 20 | + | targetHold = 400 * time.Millisecond | |
| 21 | + | warnHold = 1500 * time.Millisecond | |
| 22 | + | pulseSpan = 210 * time.Millisecond | |
| 23 | + | pulseExtra = 3.0 // logical pixels added to the border at the pulse start | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // opacityClasses are the toast steps of style.css, from opaque to faint. | |
| 27 | + | var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"} | |
| 28 | + | ||
| 29 | + | // Toast texts are constants so triggering one never allocates. | |
| 30 | + | const ( | |
| 31 | + | toastShot = "Screenshot mode" | |
| 32 | + | toastRec = "Record mode" | |
| 33 | + | toastRegion = "Region · drag to select" | |
| 34 | + | toastMonitor = "Monitor" | |
| 35 | + | toastWorkspace = "Workspace" | |
| 36 | + | toastNoWS = "No workspace found · using monitor" | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // toast is one centered message. start is zero until the first frame draws | |
| 40 | + | // it, so a slow frame does not eat the hold. | |
| 41 | + | type toast struct { | |
| 42 | + | text string | |
| 43 | + | warn bool | |
| 44 | + | hold time.Duration | |
| 45 | + | start time.Time | |
| 46 | + | step int // index in opacityClasses; valid while on | |
| 47 | + | on bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // pulse thickens the selection border and lets it shrink back. | |
| 51 | + | type pulse struct { | |
| 52 | + | start time.Time | |
| 53 | + | on bool | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // animator wakes its overlay every fxStep while an effect runs and sleeps | |
| 57 | + | // otherwise, so an idle selector does not redraw. until is written by the | |
| 58 | + | // overlay's owner loop and read by the animator goroutine. | |
| 59 | + | type animator struct { | |
| 60 | + | kick chan struct{} | |
| 61 | + | until atomic.Int64 // unix nanoseconds | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func newAnimator() *animator { return &animator{kick: make(chan struct{}, 1)} } | |
| 65 | + | ||
| 66 | + | // extend keeps the overlay redrawing until t. A nil animator does nothing. | |
| 67 | + | func (a *animator) extend(t time.Time) { | |
| 68 | + | if a == nil { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | if n := t.UnixNano(); n > a.until.Load() { | |
| 72 | + | a.until.Store(n) | |
| 73 | + | } | |
| 74 | + | select { | |
| 75 | + | case a.kick <- struct{}{}: | |
| 76 | + | default: | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // run sends a non-blocking wake every fxStep until the effects end, then waits | |
| 81 | + | // for the next kick. It returns when ctx ends. | |
| 82 | + | func (a *animator) run(ctx context.Context, wake chan<- struct{}) { | |
| 83 | + | t := time.NewTicker(fxStep) | |
| 84 | + | t.Stop() | |
| 85 | + | defer t.Stop() | |
| 86 | + | for { | |
| 87 | + | select { | |
| 88 | + | case <-ctx.Done(): | |
| 89 | + | return | |
| 90 | + | case <-a.kick: | |
| 91 | + | } | |
| 92 | + | t.Reset(fxStep) | |
| 93 | + | // One step past the end draws the frame that removes the effect. | |
| 94 | + | for time.Now().UnixNano() < a.until.Load()+int64(fxStep) { | |
| 95 | + | select { | |
| 96 | + | case <-ctx.Done(): | |
| 97 | + | return | |
| 98 | + | case <-t.C: | |
| 99 | + | select { | |
| 100 | + | case wake <- struct{}{}: | |
| 101 | + | default: | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | t.Stop() | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // showToast replaces the current toast. It never allocates. | |
| 110 | + | func (m *model) showToast(text string, hold time.Duration, warn bool) { | |
| 111 | + | m.toast = toast{text: text, warn: warn, hold: hold, on: true} | |
| 112 | + | m.fx++ | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // pulseBorder restarts the border pulse. | |
| 116 | + | func (m *model) pulseBorder() { | |
| 117 | + | m.pulse = pulse{on: true} | |
| 118 | + | m.fx++ | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // targetToast is the toast that confirms a target key. | |
| 122 | + | func targetToast(kind core.PickKind) string { | |
| 123 | + | switch kind { | |
| 124 | + | case core.PickMonitor: | |
| 125 | + | return toastMonitor | |
| 126 | + | case core.PickWorkspace: | |
| 127 | + | return toastWorkspace | |
| 128 | + | } | |
| 129 | + | return toastRegion | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func modeToast(mode ports.Mode) string { | |
| 133 | + | if mode == ports.Record { | |
| 134 | + | return toastRec | |
| 135 | + | } | |
| 136 | + | return toastShot | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // animate advances the effects to now. The view calls it before every scene; | |
| 140 | + | // it never allocates. | |
| 141 | + | func (m *model) animate(now time.Time) { | |
| 142 | + | m.edgeW = edge | |
| 143 | + | if t := &m.toast; t.on { | |
| 144 | + | if t.start.IsZero() { | |
| 145 | + | t.start = now | |
| 146 | + | m.anim.extend(now.Add(t.hold + toastFade)) | |
| 147 | + | } | |
| 148 | + | el := now.Sub(t.start) | |
| 149 | + | switch { | |
| 150 | + | case el >= t.hold+toastFade: | |
| 151 | + | t.on = false | |
| 152 | + | case el <= t.hold: | |
| 153 | + | t.step = 0 | |
| 154 | + | default: | |
| 155 | + | t.step = min(1+int((el-t.hold)/fxStep), len(opacityClasses)-1) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | if p := &m.pulse; p.on { | |
| 159 | + | if p.start.IsZero() { | |
| 160 | + | p.start = now | |
| 161 | + | m.anim.extend(now.Add(pulseSpan)) | |
| 162 | + | } | |
| 163 | + | el := now.Sub(p.start) | |
| 164 | + | if el >= pulseSpan { | |
| 165 | + | p.on = false | |
| 166 | + | } else { | |
| 167 | + | m.edgeW = edge + math.Round(pulseExtra*float64(pulseSpan-el)/float64(pulseSpan)) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package selection | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/nefercap/internal/core" | |||
| 10 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Short effects confirm a key press: a toast names the new mode or target and | |||
| 14 | + | // the selection border pulses. They only exist while the selector is open, so | |||
| 15 | + | // they never reach a capture. | |||
| 16 | + | const ( | |||
| 17 | + | fxStep = 30 * time.Millisecond | |||
| 18 | + | toastFade = 9 * fxStep // o1..o9 after the hold | |||
| 19 | + | modeHold = 600 * time.Millisecond | |||
| 20 | + | targetHold = 400 * time.Millisecond | |||
| 21 | + | warnHold = 1500 * time.Millisecond | |||
| 22 | + | pulseSpan = 210 * time.Millisecond | |||
| 23 | + | pulseExtra = 3.0 // logical pixels added to the border at the pulse start | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // opacityClasses are the toast steps of style.css, from opaque to faint. | |||
| 27 | + | var opacityClasses = [...]string{"o0", "o1", "o2", "o3", "o4", "o5", "o6", "o7", "o8", "o9"} | |||
| 28 | + | ||||
| 29 | + | // Toast texts are constants so triggering one never allocates. | |||
| 30 | + | const ( | |||
| 31 | + | toastShot = "Screenshot mode" | |||
| 32 | + | toastRec = "Record mode" | |||
| 33 | + | toastRegion = "Region · drag to select" | |||
| 34 | + | toastMonitor = "Monitor" | |||
| 35 | + | toastWorkspace = "Workspace" | |||
| 36 | + | toastNoWS = "No workspace found · using monitor" | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // toast is one centered message. start is zero until the first frame draws | |||
| 40 | + | // it, so a slow frame does not eat the hold. | |||
| 41 | + | type toast struct { | |||
| 42 | + | text string | |||
| 43 | + | warn bool | |||
| 44 | + | hold time.Duration | |||
| 45 | + | start time.Time | |||
| 46 | + | step int // index in opacityClasses; valid while on | |||
| 47 | + | on bool | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // pulse thickens the selection border and lets it shrink back. | |||
| 51 | + | type pulse struct { | |||
| 52 | + | start time.Time | |||
| 53 | + | on bool | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // animator wakes its overlay every fxStep while an effect runs and sleeps | |||
| 57 | + | // otherwise, so an idle selector does not redraw. until is written by the | |||
| 58 | + | // overlay's owner loop and read by the animator goroutine. | |||
| 59 | + | type animator struct { | |||
| 60 | + | kick chan struct{} | |||
| 61 | + | until atomic.Int64 // unix nanoseconds | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func newAnimator() *animator { return &animator{kick: make(chan struct{}, 1)} } | |||
| 65 | + | ||||
| 66 | + | // extend keeps the overlay redrawing until t. A nil animator does nothing. | |||
| 67 | + | func (a *animator) extend(t time.Time) { | |||
| 68 | + | if a == nil { | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | if n := t.UnixNano(); n > a.until.Load() { | |||
| 72 | + | a.until.Store(n) | |||
| 73 | + | } | |||
| 74 | + | select { | |||
| 75 | + | case a.kick <- struct{}{}: | |||
| 76 | + | default: | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // run sends a non-blocking wake every fxStep until the effects end, then waits | |||
| 81 | + | // for the next kick. It returns when ctx ends. | |||
| 82 | + | func (a *animator) run(ctx context.Context, wake chan<- struct{}) { | |||
| 83 | + | t := time.NewTicker(fxStep) | |||
| 84 | + | t.Stop() | |||
| 85 | + | defer t.Stop() | |||
| 86 | + | for { | |||
| 87 | + | select { | |||
| 88 | + | case <-ctx.Done(): | |||
| 89 | + | return | |||
| 90 | + | case <-a.kick: | |||
| 91 | + | } | |||
| 92 | + | t.Reset(fxStep) | |||
| 93 | + | // One step past the end draws the frame that removes the effect. | |||
| 94 | + | for time.Now().UnixNano() < a.until.Load()+int64(fxStep) { | |||
| 95 | + | select { | |||
| 96 | + | case <-ctx.Done(): | |||
| 97 | + | return | |||
| 98 | + | case <-t.C: | |||
| 99 | + | select { | |||
| 100 | + | case wake <- struct{}{}: | |||
| 101 | + | default: | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | t.Stop() | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // showToast replaces the current toast. It never allocates. | |||
| 110 | + | func (m *model) showToast(text string, hold time.Duration, warn bool) { | |||
| 111 | + | m.toast = toast{text: text, warn: warn, hold: hold, on: true} | |||
| 112 | + | m.fx++ | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // pulseBorder restarts the border pulse. | |||
| 116 | + | func (m *model) pulseBorder() { | |||
| 117 | + | m.pulse = pulse{on: true} | |||
| 118 | + | m.fx++ | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // targetToast is the toast that confirms a target key. | |||
| 122 | + | func targetToast(kind core.PickKind) string { | |||
| 123 | + | switch kind { | |||
| 124 | + | case core.PickMonitor: | |||
| 125 | + | return toastMonitor | |||
| 126 | + | case core.PickWorkspace: | |||
| 127 | + | return toastWorkspace | |||
| 128 | + | } | |||
| 129 | + | return toastRegion | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func modeToast(mode ports.Mode) string { | |||
| 133 | + | if mode == ports.Record { | |||
| 134 | + | return toastRec | |||
| 135 | + | } | |||
| 136 | + | return toastShot | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // animate advances the effects to now. The view calls it before every scene; | |||
| 140 | + | // it never allocates. | |||
| 141 | + | func (m *model) animate(now time.Time) { | |||
| 142 | + | m.edgeW = edge | |||
| 143 | + | if t := &m.toast; t.on { | |||
| 144 | + | if t.start.IsZero() { | |||
| 145 | + | t.start = now | |||
| 146 | + | m.anim.extend(now.Add(t.hold + toastFade)) | |||
| 147 | + | } | |||
| 148 | + | el := now.Sub(t.start) | |||
| 149 | + | switch { | |||
| 150 | + | case el >= t.hold+toastFade: | |||
| 151 | + | t.on = false | |||
| 152 | + | case el <= t.hold: | |||
| 153 | + | t.step = 0 | |||
| 154 | + | default: | |||
| 155 | + | t.step = min(1+int((el-t.hold)/fxStep), len(opacityClasses)-1) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | if p := &m.pulse; p.on { | |||
| 159 | + | if p.start.IsZero() { | |||
| 160 | + | p.start = now | |||
| 161 | + | m.anim.extend(now.Add(pulseSpan)) | |||
| 162 | + | } | |||
| 163 | + | el := now.Sub(p.start) | |||
| 164 | + | if el >= pulseSpan { | |||
| 165 | + | p.on = false | |||
| 166 | + | } else { | |||
| 167 | + | m.edgeW = edge + math.Round(pulseExtra*float64(pulseSpan-el)/float64(pulseSpan)) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | } | |||
A internal/adapters/selection/fx_test.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package selection | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestTargetKeysShowToastAndPulse(t *testing.T) { | |
| 16 | + | m := newTestModel(t) | |
| 17 | + | for _, c := range []struct { | |
| 18 | + | key string | |
| 19 | + | kind core.PickKind | |
| 20 | + | text string | |
| 21 | + | }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} { | |
| 22 | + | assert.True(t, m.input(key(c.key)), c.key) | |
| 23 | + | assert.Equal(t, c.kind, m.picker.Kind()) | |
| 24 | + | assert.Equal(t, c.text, m.toast.text) | |
| 25 | + | assert.False(t, m.toast.warn) | |
| 26 | + | assert.True(t, m.pulse.on) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) { | |
| 31 | + | m, _ := newBareModel() | |
| 32 | + | m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}) | |
| 33 | + | m.resize(1920, 1080, 1) | |
| 34 | + | m.input(key("w")) | |
| 35 | + | assert.Equal(t, core.PickWorkspace, m.picker.Kind()) | |
| 36 | + | assert.Equal(t, toastWorkspace, m.toast.text) | |
| 37 | + | assert.False(t, m.toast.warn) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) { | |
| 41 | + | m := newTestModel(t) | |
| 42 | + | assert.True(t, m.input(key("w"))) | |
| 43 | + | assert.Equal(t, core.PickMonitor, m.picker.Kind()) | |
| 44 | + | assert.Equal(t, toastNoWS, m.toast.text) | |
| 45 | + | assert.True(t, m.toast.warn) | |
| 46 | + | m.input(key("Return")) | |
| 47 | + | require.Equal(t, endAccepted, m.end) | |
| 48 | + | res, ok, err := m.sess.outcome(context.Background()) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.True(t, ok) | |
| 51 | + | assert.Equal(t, core.PickMonitor, res.Kind) | |
| 52 | + | assert.Equal(t, uint32(7), res.OutputID) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestTargetKeysIgnoredWhileDragging(t *testing.T) { | |
| 56 | + | m := newTestModel(t) | |
| 57 | + | m.input(button(100, 100, true)) | |
| 58 | + | m.input(motion(300, 300)) | |
| 59 | + | require.True(t, m.picker.Dragging()) | |
| 60 | + | for _, k := range []string{"m", "w", "r"} { | |
| 61 | + | m.input(key(k)) | |
| 62 | + | assert.True(t, m.picker.Dragging()) | |
| 63 | + | assert.False(t, m.toast.on, k) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestTabShowsModeToastOnEveryOverlay(t *testing.T) { | |
| 68 | + | sess, _ := testSession() | |
| 69 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |
| 70 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |
| 71 | + | a.toggle, b.toggle = true, true | |
| 72 | + | a.resize(1920, 1080, 1) | |
| 73 | + | b.resize(1280, 720, 1) | |
| 74 | + | a.input(key("Tab")) | |
| 75 | + | assert.Equal(t, toastRec, a.toast.text) | |
| 76 | + | b.syncMode() // what b's view does when woken | |
| 77 | + | assert.Equal(t, toastRec, b.toast.text) | |
| 78 | + | assert.True(t, b.pulse.on) | |
| 79 | + | a.input(key("Tab")) | |
| 80 | + | assert.Equal(t, toastShot, a.toast.text) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestToastHoldsFadesAndEnds(t *testing.T) { | |
| 84 | + | m := newTestModel(t) | |
| 85 | + | m.input(key("m")) | |
| 86 | + | t0 := time.Unix(1000, 0) | |
| 87 | + | m.animate(t0) | |
| 88 | + | assert.Equal(t, 0, m.toast.step) | |
| 89 | + | assert.Greater(t, m.scene().toast.w, 0.0) | |
| 90 | + | m.animate(t0.Add(targetHold)) | |
| 91 | + | assert.Equal(t, 0, m.toast.step, "held at full opacity") | |
| 92 | + | m.animate(t0.Add(targetHold + fxStep)) | |
| 93 | + | assert.Equal(t, 2, m.toast.step) | |
| 94 | + | m.animate(t0.Add(targetHold + toastFade - time.Millisecond)) | |
| 95 | + | assert.Equal(t, len(opacityClasses)-1, m.toast.step) | |
| 96 | + | m.animate(t0.Add(targetHold + toastFade)) | |
| 97 | + | assert.False(t, m.toast.on) | |
| 98 | + | assert.Equal(t, frect{}, m.scene().toast) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestPulseThickensThenRestoresTheBorder(t *testing.T) { | |
| 102 | + | m := newTestModel(t) | |
| 103 | + | m.input(key("m")) | |
| 104 | + | t0 := time.Unix(1000, 0) | |
| 105 | + | m.animate(t0) | |
| 106 | + | assert.Equal(t, edge+pulseExtra, m.edgeW) | |
| 107 | + | assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0]) | |
| 108 | + | m.animate(t0.Add(pulseSpan / 2)) | |
| 109 | + | assert.Greater(t, m.edgeW, edge) | |
| 110 | + | assert.Less(t, m.edgeW, edge+pulseExtra) | |
| 111 | + | m.animate(t0.Add(pulseSpan)) | |
| 112 | + | assert.Equal(t, edge, m.edgeW) | |
| 113 | + | assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0]) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestPulseNeverExceedsATinyRegion(t *testing.T) { | |
| 117 | + | m := newTestModel(t) | |
| 118 | + | m.input(button(100, 100, true)) | |
| 119 | + | m.input(motion(103, 103)) | |
| 120 | + | m.pulseBorder() | |
| 121 | + | m.animate(time.Unix(1000, 0)) | |
| 122 | + | for _, e := range m.scene().edges { | |
| 123 | + | assert.LessOrEqual(t, e.w, 3.0) | |
| 124 | + | assert.LessOrEqual(t, e.h, 3.0) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestChipsShowModeAndTarget(t *testing.T) { | |
| 129 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 130 | + | s := m.scene() | |
| 131 | + | assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip") | |
| 132 | + | assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab") | |
| 133 | + | for i := range s.kinds { | |
| 134 | + | assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i]) | |
| 135 | + | } | |
| 136 | + | assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output") | |
| 137 | + | assert.False(t, overlaps(s.badge, s.target)) | |
| 138 | + | assert.False(t, overlaps(s.target, s.header)) | |
| 139 | + | ||
| 140 | + | fixed := toggleModel(t, ports.Record, false) | |
| 141 | + | s = fixed.scene() | |
| 142 | + | assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows") | |
| 143 | + | assert.Greater(t, s.modes[1].w, 0.0) | |
| 144 | + | ||
| 145 | + | assert.Equal(t, "shot", modeClass(ports.Screenshot)) | |
| 146 | + | assert.Equal(t, "rec", modeClass(ports.Record)) | |
| 147 | + | assert.Contains(t, styleSheet, "box.chip.shot") | |
| 148 | + | assert.Contains(t, styleSheet, "box.chip.rec") | |
| 149 | + | assert.Contains(t, styleSheet, "box.edge.rec") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) { | |
| 153 | + | for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} { | |
| 154 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 155 | + | m.resize(w, 480, 1) | |
| 156 | + | s := m.scene() | |
| 157 | + | for _, r := range append(s.modes[:], s.kinds[:]...) { | |
| 158 | + | if r.w == 0 { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | assert.GreaterOrEqual(t, r.x, 0.0) | |
| 162 | + | assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w) | |
| 163 | + | assert.LessOrEqual(t, r.y+r.h, 480.0) | |
| 164 | + | } | |
| 165 | + | if w >= 640 { | |
| 166 | + | assert.False(t, overlaps(s.target, s.header), "target/header at %d", w) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestEffectsAllocations(t *testing.T) { | |
| 172 | + | if raceEnabled { | |
| 173 | + | t.Skip("the race detector adds allocations") | |
| 174 | + | } | |
| 175 | + | // A target change rebuilds the footer legend once (covered by the footer | |
| 176 | + | // tests); confirming the same target only replays the effects. | |
| 177 | + | m := newTestModel(t) | |
| 178 | + | m.input(key("w")) // falls back to the monitor | |
| 179 | + | now := time.Unix(1000, 0) | |
| 180 | + | n := testing.AllocsPerRun(100, func() { | |
| 181 | + | m.input(key("m")) | |
| 182 | + | m.input(key("w")) | |
| 183 | + | now = now.Add(fxStep) | |
| 184 | + | m.animate(now) | |
| 185 | + | _ = m.scene() | |
| 186 | + | }) | |
| 187 | + | assert.Zero(t, n, "toasts, pulses and their frames allocate nothing") | |
| 188 | + | n = testing.AllocsPerRun(100, func() { | |
| 189 | + | m.input(key("r")) | |
| 190 | + | m.input(key("m")) | |
| 191 | + | }) | |
| 192 | + | t.Logf("target switch allocs/run: %v", n) | |
| 193 | + | // Measured on main before the effects: 3 (two legends and a size label). | |
| 194 | + | assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) { | |
| 198 | + | a := newAnimator() | |
| 199 | + | wake := make(chan struct{}, 1) | |
| 200 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 201 | + | defer cancel() // a failed assertion must not leak the animator | |
| 202 | + | done := make(chan struct{}) | |
| 203 | + | go func() { a.run(ctx, wake); close(done) }() | |
| 204 | + | ||
| 205 | + | select { | |
| 206 | + | case <-wake: | |
| 207 | + | t.Fatal("idle animator woke the overlay") | |
| 208 | + | case <-time.After(3 * fxStep): | |
| 209 | + | } | |
| 210 | + | a.extend(time.Now().Add(2 * fxStep)) | |
| 211 | + | select { | |
| 212 | + | case <-wake: | |
| 213 | + | case <-time.After(time.Second): | |
| 214 | + | t.Fatal("running effect did not wake the overlay") | |
| 215 | + | } | |
| 216 | + | time.Sleep(5 * fxStep) | |
| 217 | + | for len(wake) > 0 { | |
| 218 | + | <-wake | |
| 219 | + | } | |
| 220 | + | select { | |
| 221 | + | case <-wake: | |
| 222 | + | t.Fatal("ended effect kept waking the overlay") | |
| 223 | + | case <-time.After(3 * fxStep): | |
| 224 | + | } | |
| 225 | + | cancel() | |
| 226 | + | select { | |
| 227 | + | case <-done: | |
| 228 | + | case <-time.After(time.Second): | |
| 229 | + | t.Fatal("animator did not stop with its context") | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestNilAnimatorIsSafe(t *testing.T) { | |
| 234 | + | var a *animator | |
| 235 | + | a.extend(time.Now()) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestChipStatesFollowModeAndTarget(t *testing.T) { | |
| 239 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 240 | + | s := m.scene() | |
| 241 | + | assert.Equal(t, [2]string{"shot", "idle"}, s.modeState) | |
| 242 | + | assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here") | |
| 243 | + | m.input(key("m")) | |
| 244 | + | m.input(key("Tab")) | |
| 245 | + | s = m.scene() | |
| 246 | + | assert.Equal(t, [2]string{"idle", "rec"}, s.modeState) | |
| 247 | + | assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState) | |
| 248 | + | } | |
| @@ -0,0 +1,248 @@ | |||||
| 1 | + | package selection | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefercap/internal/core" | |||
| 12 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestTargetKeysShowToastAndPulse(t *testing.T) { | |||
| 16 | + | m := newTestModel(t) | |||
| 17 | + | for _, c := range []struct { | |||
| 18 | + | key string | |||
| 19 | + | kind core.PickKind | |||
| 20 | + | text string | |||
| 21 | + | }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} { | |||
| 22 | + | assert.True(t, m.input(key(c.key)), c.key) | |||
| 23 | + | assert.Equal(t, c.kind, m.picker.Kind()) | |||
| 24 | + | assert.Equal(t, c.text, m.toast.text) | |||
| 25 | + | assert.False(t, m.toast.warn) | |||
| 26 | + | assert.True(t, m.pulse.on) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) { | |||
| 31 | + | m, _ := newBareModel() | |||
| 32 | + | m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}) | |||
| 33 | + | m.resize(1920, 1080, 1) | |||
| 34 | + | m.input(key("w")) | |||
| 35 | + | assert.Equal(t, core.PickWorkspace, m.picker.Kind()) | |||
| 36 | + | assert.Equal(t, toastWorkspace, m.toast.text) | |||
| 37 | + | assert.False(t, m.toast.warn) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) { | |||
| 41 | + | m := newTestModel(t) | |||
| 42 | + | assert.True(t, m.input(key("w"))) | |||
| 43 | + | assert.Equal(t, core.PickMonitor, m.picker.Kind()) | |||
| 44 | + | assert.Equal(t, toastNoWS, m.toast.text) | |||
| 45 | + | assert.True(t, m.toast.warn) | |||
| 46 | + | m.input(key("Return")) | |||
| 47 | + | require.Equal(t, endAccepted, m.end) | |||
| 48 | + | res, ok, err := m.sess.outcome(context.Background()) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | require.True(t, ok) | |||
| 51 | + | assert.Equal(t, core.PickMonitor, res.Kind) | |||
| 52 | + | assert.Equal(t, uint32(7), res.OutputID) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestTargetKeysIgnoredWhileDragging(t *testing.T) { | |||
| 56 | + | m := newTestModel(t) | |||
| 57 | + | m.input(button(100, 100, true)) | |||
| 58 | + | m.input(motion(300, 300)) | |||
| 59 | + | require.True(t, m.picker.Dragging()) | |||
| 60 | + | for _, k := range []string{"m", "w", "r"} { | |||
| 61 | + | m.input(key(k)) | |||
| 62 | + | assert.True(t, m.picker.Dragging()) | |||
| 63 | + | assert.False(t, m.toast.on, k) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestTabShowsModeToastOnEveryOverlay(t *testing.T) { | |||
| 68 | + | sess, _ := testSession() | |||
| 69 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |||
| 70 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |||
| 71 | + | a.toggle, b.toggle = true, true | |||
| 72 | + | a.resize(1920, 1080, 1) | |||
| 73 | + | b.resize(1280, 720, 1) | |||
| 74 | + | a.input(key("Tab")) | |||
| 75 | + | assert.Equal(t, toastRec, a.toast.text) | |||
| 76 | + | b.syncMode() // what b's view does when woken | |||
| 77 | + | assert.Equal(t, toastRec, b.toast.text) | |||
| 78 | + | assert.True(t, b.pulse.on) | |||
| 79 | + | a.input(key("Tab")) | |||
| 80 | + | assert.Equal(t, toastShot, a.toast.text) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestToastHoldsFadesAndEnds(t *testing.T) { | |||
| 84 | + | m := newTestModel(t) | |||
| 85 | + | m.input(key("m")) | |||
| 86 | + | t0 := time.Unix(1000, 0) | |||
| 87 | + | m.animate(t0) | |||
| 88 | + | assert.Equal(t, 0, m.toast.step) | |||
| 89 | + | assert.Greater(t, m.scene().toast.w, 0.0) | |||
| 90 | + | m.animate(t0.Add(targetHold)) | |||
| 91 | + | assert.Equal(t, 0, m.toast.step, "held at full opacity") | |||
| 92 | + | m.animate(t0.Add(targetHold + fxStep)) | |||
| 93 | + | assert.Equal(t, 2, m.toast.step) | |||
| 94 | + | m.animate(t0.Add(targetHold + toastFade - time.Millisecond)) | |||
| 95 | + | assert.Equal(t, len(opacityClasses)-1, m.toast.step) | |||
| 96 | + | m.animate(t0.Add(targetHold + toastFade)) | |||
| 97 | + | assert.False(t, m.toast.on) | |||
| 98 | + | assert.Equal(t, frect{}, m.scene().toast) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestPulseThickensThenRestoresTheBorder(t *testing.T) { | |||
| 102 | + | m := newTestModel(t) | |||
| 103 | + | m.input(key("m")) | |||
| 104 | + | t0 := time.Unix(1000, 0) | |||
| 105 | + | m.animate(t0) | |||
| 106 | + | assert.Equal(t, edge+pulseExtra, m.edgeW) | |||
| 107 | + | assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0]) | |||
| 108 | + | m.animate(t0.Add(pulseSpan / 2)) | |||
| 109 | + | assert.Greater(t, m.edgeW, edge) | |||
| 110 | + | assert.Less(t, m.edgeW, edge+pulseExtra) | |||
| 111 | + | m.animate(t0.Add(pulseSpan)) | |||
| 112 | + | assert.Equal(t, edge, m.edgeW) | |||
| 113 | + | assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0]) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestPulseNeverExceedsATinyRegion(t *testing.T) { | |||
| 117 | + | m := newTestModel(t) | |||
| 118 | + | m.input(button(100, 100, true)) | |||
| 119 | + | m.input(motion(103, 103)) | |||
| 120 | + | m.pulseBorder() | |||
| 121 | + | m.animate(time.Unix(1000, 0)) | |||
| 122 | + | for _, e := range m.scene().edges { | |||
| 123 | + | assert.LessOrEqual(t, e.w, 3.0) | |||
| 124 | + | assert.LessOrEqual(t, e.h, 3.0) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestChipsShowModeAndTarget(t *testing.T) { | |||
| 129 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 130 | + | s := m.scene() | |||
| 131 | + | assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip") | |||
| 132 | + | assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab") | |||
| 133 | + | for i := range s.kinds { | |||
| 134 | + | assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i]) | |||
| 135 | + | } | |||
| 136 | + | assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output") | |||
| 137 | + | assert.False(t, overlaps(s.badge, s.target)) | |||
| 138 | + | assert.False(t, overlaps(s.target, s.header)) | |||
| 139 | + | ||||
| 140 | + | fixed := toggleModel(t, ports.Record, false) | |||
| 141 | + | s = fixed.scene() | |||
| 142 | + | assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows") | |||
| 143 | + | assert.Greater(t, s.modes[1].w, 0.0) | |||
| 144 | + | ||||
| 145 | + | assert.Equal(t, "shot", modeClass(ports.Screenshot)) | |||
| 146 | + | assert.Equal(t, "rec", modeClass(ports.Record)) | |||
| 147 | + | assert.Contains(t, styleSheet, "box.chip.shot") | |||
| 148 | + | assert.Contains(t, styleSheet, "box.chip.rec") | |||
| 149 | + | assert.Contains(t, styleSheet, "box.edge.rec") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) { | |||
| 153 | + | for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} { | |||
| 154 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 155 | + | m.resize(w, 480, 1) | |||
| 156 | + | s := m.scene() | |||
| 157 | + | for _, r := range append(s.modes[:], s.kinds[:]...) { | |||
| 158 | + | if r.w == 0 { | |||
| 159 | + | continue | |||
| 160 | + | } | |||
| 161 | + | assert.GreaterOrEqual(t, r.x, 0.0) | |||
| 162 | + | assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w) | |||
| 163 | + | assert.LessOrEqual(t, r.y+r.h, 480.0) | |||
| 164 | + | } | |||
| 165 | + | if w >= 640 { | |||
| 166 | + | assert.False(t, overlaps(s.target, s.header), "target/header at %d", w) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestEffectsAllocations(t *testing.T) { | |||
| 172 | + | if raceEnabled { | |||
| 173 | + | t.Skip("the race detector adds allocations") | |||
| 174 | + | } | |||
| 175 | + | // A target change rebuilds the footer legend once (covered by the footer | |||
| 176 | + | // tests); confirming the same target only replays the effects. | |||
| 177 | + | m := newTestModel(t) | |||
| 178 | + | m.input(key("w")) // falls back to the monitor | |||
| 179 | + | now := time.Unix(1000, 0) | |||
| 180 | + | n := testing.AllocsPerRun(100, func() { | |||
| 181 | + | m.input(key("m")) | |||
| 182 | + | m.input(key("w")) | |||
| 183 | + | now = now.Add(fxStep) | |||
| 184 | + | m.animate(now) | |||
| 185 | + | _ = m.scene() | |||
| 186 | + | }) | |||
| 187 | + | assert.Zero(t, n, "toasts, pulses and their frames allocate nothing") | |||
| 188 | + | n = testing.AllocsPerRun(100, func() { | |||
| 189 | + | m.input(key("r")) | |||
| 190 | + | m.input(key("m")) | |||
| 191 | + | }) | |||
| 192 | + | t.Logf("target switch allocs/run: %v", n) | |||
| 193 | + | // Measured on main before the effects: 3 (two legends and a size label). | |||
| 194 | + | assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch") | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) { | |||
| 198 | + | a := newAnimator() | |||
| 199 | + | wake := make(chan struct{}, 1) | |||
| 200 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 201 | + | defer cancel() // a failed assertion must not leak the animator | |||
| 202 | + | done := make(chan struct{}) | |||
| 203 | + | go func() { a.run(ctx, wake); close(done) }() | |||
| 204 | + | ||||
| 205 | + | select { | |||
| 206 | + | case <-wake: | |||
| 207 | + | t.Fatal("idle animator woke the overlay") | |||
| 208 | + | case <-time.After(3 * fxStep): | |||
| 209 | + | } | |||
| 210 | + | a.extend(time.Now().Add(2 * fxStep)) | |||
| 211 | + | select { | |||
| 212 | + | case <-wake: | |||
| 213 | + | case <-time.After(time.Second): | |||
| 214 | + | t.Fatal("running effect did not wake the overlay") | |||
| 215 | + | } | |||
| 216 | + | time.Sleep(5 * fxStep) | |||
| 217 | + | for len(wake) > 0 { | |||
| 218 | + | <-wake | |||
| 219 | + | } | |||
| 220 | + | select { | |||
| 221 | + | case <-wake: | |||
| 222 | + | t.Fatal("ended effect kept waking the overlay") | |||
| 223 | + | case <-time.After(3 * fxStep): | |||
| 224 | + | } | |||
| 225 | + | cancel() | |||
| 226 | + | select { | |||
| 227 | + | case <-done: | |||
| 228 | + | case <-time.After(time.Second): | |||
| 229 | + | t.Fatal("animator did not stop with its context") | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestNilAnimatorIsSafe(t *testing.T) { | |||
| 234 | + | var a *animator | |||
| 235 | + | a.extend(time.Now()) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestChipStatesFollowModeAndTarget(t *testing.T) { | |||
| 239 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 240 | + | s := m.scene() | |||
| 241 | + | assert.Equal(t, [2]string{"shot", "idle"}, s.modeState) | |||
| 242 | + | assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here") | |||
| 243 | + | m.input(key("m")) | |||
| 244 | + | m.input(key("Tab")) | |||
| 245 | + | s = m.scene() | |||
| 246 | + | assert.Equal(t, [2]string{"idle", "rec"}, s.modeState) | |||
| 247 | + | assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState) | |||
| 248 | + | } | |||
M internal/adapters/selection/model.go +43 -4
| @@ -63,6 +63,14 @@ type model struct { | |||
| 63 | 63 | footerMode ports.Mode | |
| 64 | 64 | footerWorkspace bool | |
| 65 | 65 | label label | |
| 66 | + | ||
| 67 | + | // Key feedback: a toast and a border pulse, advanced by animate. fx counts | |
| 68 | + | // triggered effects so input reports them as a visible change. | |
| 69 | + | toast toast | |
| 70 | + | pulse pulse | |
| 71 | + | fx uint32 | |
| 72 | + | edgeW float64 // border width this frame; edge outside a pulse | |
| 73 | + | anim *animator // nil in tests: effects still advance, nothing wakes | |
| 66 | 74 | } | |
| 67 | 75 | ||
| 68 | 76 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | |
| @@ -89,6 +97,8 @@ func modeAfter(base ports.Mode, n uint32) ports.Mode { | |||
| 89 | 97 | func (m *model) syncMode() { | |
| 90 | 98 | if n := m.sess.toggles.Load(); n != m.seen { | |
| 91 | 99 | m.seen, m.mode = n, modeAfter(m.base, n) | |
| 100 | + | m.showToast(modeToast(m.mode), modeHold, false) | |
| 101 | + | m.pulseBorder() | |
| 92 | 102 | } | |
| 93 | 103 | } | |
| 94 | 104 | ||
| @@ -199,11 +209,12 @@ type visible struct { | |||
| 199 | 209 | kind core.PickKind | |
| 200 | 210 | grid bool | |
| 201 | 211 | status core.PickStatus | |
| 212 | + | fx uint32 | |
| 202 | 213 | } | |
| 203 | 214 | ||
| 204 | 215 | func (m *model) snapshot() visible { | |
| 205 | 216 | r, _ := m.picker.Rect() | |
| 206 | - | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | |
| 217 | + | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx} | |
| 207 | 218 | } | |
| 208 | 219 | ||
| 209 | 220 | // input is the OnInput callback. It reports whether the drawn state changed, | |
| @@ -242,11 +253,11 @@ func (m *model) key(name string) { | |||
| 242 | 253 | case "Escape": | |
| 243 | 254 | m.picker.Cancel() | |
| 244 | 255 | case "m", "M": | |
| 245 | - | m.picker.SelectMonitor() | |
| 256 | + | m.selectTarget(core.PickMonitor) | |
| 246 | 257 | case "w", "W": | |
| 247 | - | m.picker.SelectWorkspace() // a no-op until a real workspace is supplied | |
| 258 | + | m.selectTarget(core.PickWorkspace) | |
| 248 | 259 | case "r", "R": | |
| 249 | - | m.picker.SelectRegion() | |
| 260 | + | m.selectTarget(core.PickRegion) | |
| 250 | 261 | case "g", "G": | |
| 251 | 262 | m.grid = !m.grid | |
| 252 | 263 | case "Tab": | |
| @@ -265,6 +276,34 @@ func (m *model) key(name string) { | |||
| 265 | 276 | } | |
| 266 | 277 | } | |
| 267 | 278 | ||
| 279 | + | // selectTarget switches the picker target and confirms it with a toast and a | |
| 280 | + | // border pulse, even when the target was already selected. W without a | |
| 281 | + | // workspace on this output falls back to the monitor and says so. A drag in | |
| 282 | + | // progress keeps its target and shows nothing. | |
| 283 | + | func (m *model) selectTarget(kind core.PickKind) { | |
| 284 | + | if m.picker.Dragging() { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | if kind == core.PickWorkspace { | |
| 288 | + | if _, _, ok := m.picker.Workspace(); !ok { | |
| 289 | + | m.picker.SelectMonitor() | |
| 290 | + | m.showToast(toastNoWS, warnHold, true) | |
| 291 | + | m.pulseBorder() | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | switch kind { | |
| 296 | + | case core.PickMonitor: | |
| 297 | + | m.picker.SelectMonitor() | |
| 298 | + | case core.PickWorkspace: | |
| 299 | + | m.picker.SelectWorkspace() | |
| 300 | + | default: | |
| 301 | + | m.picker.SelectRegion() | |
| 302 | + | } | |
| 303 | + | m.showToast(targetToast(kind), targetHold, false) | |
| 304 | + | m.pulseBorder() | |
| 305 | + | } | |
| 306 | + | ||
| 268 | 307 | // keyName is the xkb name of the keysyms the selector handles, "" for the | |
| 269 | 308 | // others. Latin-1 keysyms are their code points. It never allocates. | |
| 270 | 309 | func keyName(sym uint32) string { | |
| @@ -63,6 +63,14 @@ type model struct { | |||||
| 63 | footerMode ports.Mode | 63 | footerMode ports.Mode | ||
| 64 | footerWorkspace bool | 64 | footerWorkspace bool | ||
| 65 | label label | 65 | label label | ||
| 66 | + | ||||
| 67 | + | // Key feedback: a toast and a border pulse, advanced by animate. fx counts | |||
| 68 | + | // triggered effects so input reports them as a visible change. | |||
| 69 | + | toast toast | |||
| 70 | + | pulse pulse | |||
| 71 | + | fx uint32 | |||
| 72 | + | edgeW float64 // border width this frame; edge outside a pulse | |||
| 73 | + | anim *animator // nil in tests: effects still advance, nothing wakes | |||
| 66 | } | 74 | } | ||
| 67 | 75 | ||||
| 68 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | 76 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | ||
| @@ -89,6 +97,8 @@ func modeAfter(base ports.Mode, n uint32) ports.Mode { | |||||
| 89 | func (m *model) syncMode() { | 97 | func (m *model) syncMode() { | ||
| 90 | if n := m.sess.toggles.Load(); n != m.seen { | 98 | if n := m.sess.toggles.Load(); n != m.seen { | ||
| 91 | m.seen, m.mode = n, modeAfter(m.base, n) | 99 | m.seen, m.mode = n, modeAfter(m.base, n) | ||
| 100 | + | m.showToast(modeToast(m.mode), modeHold, false) | |||
| 101 | + | m.pulseBorder() | |||
| 92 | } | 102 | } | ||
| 93 | } | 103 | } | ||
| 94 | 104 | ||||
| @@ -199,11 +209,12 @@ type visible struct { | |||||
| 199 | kind core.PickKind | 209 | kind core.PickKind | ||
| 200 | grid bool | 210 | grid bool | ||
| 201 | status core.PickStatus | 211 | status core.PickStatus | ||
| 212 | + | fx uint32 | |||
| 202 | } | 213 | } | ||
| 203 | 214 | ||||
| 204 | func (m *model) snapshot() visible { | 215 | func (m *model) snapshot() visible { | ||
| 205 | r, _ := m.picker.Rect() | 216 | r, _ := m.picker.Rect() | ||
| 206 | - | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | 217 | + | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status(), m.fx} |
| 207 | } | 218 | } | ||
| 208 | 219 | ||||
| 209 | // input is the OnInput callback. It reports whether the drawn state changed, | 220 | // input is the OnInput callback. It reports whether the drawn state changed, | ||
| @@ -242,11 +253,11 @@ func (m *model) key(name string) { | |||||
| 242 | case "Escape": | 253 | case "Escape": | ||
| 243 | m.picker.Cancel() | 254 | m.picker.Cancel() | ||
| 244 | case "m", "M": | 255 | case "m", "M": | ||
| 245 | - | m.picker.SelectMonitor() | 256 | + | m.selectTarget(core.PickMonitor) |
| 246 | case "w", "W": | 257 | case "w", "W": | ||
| 247 | - | m.picker.SelectWorkspace() // a no-op until a real workspace is supplied | 258 | + | m.selectTarget(core.PickWorkspace) |
| 248 | case "r", "R": | 259 | case "r", "R": | ||
| 249 | - | m.picker.SelectRegion() | 260 | + | m.selectTarget(core.PickRegion) |
| 250 | case "g", "G": | 261 | case "g", "G": | ||
| 251 | m.grid = !m.grid | 262 | m.grid = !m.grid | ||
| 252 | case "Tab": | 263 | case "Tab": | ||
| @@ -265,6 +276,34 @@ func (m *model) key(name string) { | |||||
| 265 | } | 276 | } | ||
| 266 | } | 277 | } | ||
| 267 | 278 | ||||
| 279 | + | // selectTarget switches the picker target and confirms it with a toast and a | |||
| 280 | + | // border pulse, even when the target was already selected. W without a | |||
| 281 | + | // workspace on this output falls back to the monitor and says so. A drag in | |||
| 282 | + | // progress keeps its target and shows nothing. | |||
| 283 | + | func (m *model) selectTarget(kind core.PickKind) { | |||
| 284 | + | if m.picker.Dragging() { | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | if kind == core.PickWorkspace { | |||
| 288 | + | if _, _, ok := m.picker.Workspace(); !ok { | |||
| 289 | + | m.picker.SelectMonitor() | |||
| 290 | + | m.showToast(toastNoWS, warnHold, true) | |||
| 291 | + | m.pulseBorder() | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | switch kind { | |||
| 296 | + | case core.PickMonitor: | |||
| 297 | + | m.picker.SelectMonitor() | |||
| 298 | + | case core.PickWorkspace: | |||
| 299 | + | m.picker.SelectWorkspace() | |||
| 300 | + | default: | |||
| 301 | + | m.picker.SelectRegion() | |||
| 302 | + | } | |||
| 303 | + | m.showToast(targetToast(kind), targetHold, false) | |||
| 304 | + | m.pulseBorder() | |||
| 305 | + | } | |||
| 306 | + | ||||
| 268 | // keyName is the xkb name of the keysyms the selector handles, "" for the | 307 | // keyName is the xkb name of the keysyms the selector handles, "" for the | ||
| 269 | // others. Latin-1 keysyms are their code points. It never allocates. | 308 | // others. Latin-1 keysyms are their code points. It never allocates. | ||
| 270 | func keyName(sym uint32) string { | 309 | func keyName(sym uint32) string { | ||
M internal/adapters/selection/scene.go +144 -22
| @@ -2,6 +2,7 @@ package selection | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | + | "unicode/utf8" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/nefercap/internal/core" | |
| 7 | 8 | "github.com/bnema/nefercap/internal/ports" | |
| @@ -18,6 +19,10 @@ const ( | |||
| 18 | 19 | headerH = labelH | |
| 19 | 20 | edge = 1.0 // thin selection lines | |
| 20 | 21 | margin = baseFont | |
| 22 | + | chipGap = baseFont * 0.25 // between chips of one group | |
| 23 | + | groupGap = baseFont * 0.75 // between the mode and target groups | |
| 24 | + | toastFont = baseFont * 1.5 | |
| 25 | + | toastH = toastFont * 2 | |
| 21 | 26 | ) | |
| 22 | 27 | ||
| 23 | 28 | // noIndicatorHint is shown in record mode on a compositor that cannot keep a | |
| @@ -69,8 +74,8 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica | |||
| 69 | 74 | return b.String() | |
| 70 | 75 | } | |
| 71 | 76 | ||
| 72 | - | // badgeText is the always-visible mode badge. Both modes share the neutral | |
| 73 | - | // tag style: a red badge would read as an active recording. | |
| 77 | + | // badgeText is the label of a mode chip. No mode is red: red would read as | |
| 78 | + | // an active recording. | |
| 74 | 79 | func badgeText(mode ports.Mode) string { | |
| 75 | 80 | if mode == ports.Record { | |
| 76 | 81 | return "REC" | |
| @@ -78,6 +83,22 @@ func badgeText(mode ports.Mode) string { | |||
| 78 | 83 | return "SHOT" | |
| 79 | 84 | } | |
| 80 | 85 | ||
| 86 | + | // modeClass is the stylesheet class that colors the active mode: cyan for | |
| 87 | + | // screenshots, amber for recordings. | |
| 88 | + | func modeClass(mode ports.Mode) string { | |
| 89 | + | if mode == ports.Record { | |
| 90 | + | return "rec" | |
| 91 | + | } | |
| 92 | + | return "shot" | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Chip labels: the key, then the target. Indexes match targetKinds. | |
| 96 | + | var ( | |
| 97 | + | modeChips = [2]ports.Mode{ports.Screenshot, ports.Record} | |
| 98 | + | targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace} | |
| 99 | + | targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"} | |
| 100 | + | ) | |
| 101 | + | ||
| 81 | 102 | // frect is a float rectangle in surface-local logical pixels, the unit | |
| 82 | 103 | // Node.Rect takes. | |
| 83 | 104 | type frect struct{ x, y, w, h float64 } | |
| @@ -90,8 +111,16 @@ type scene struct { | |||
| 90 | 111 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | |
| 91 | 112 | label frect | |
| 92 | 113 | header frect | |
| 93 | - | badge frect // top-left mode badge | |
| 94 | - | footer frect | |
| 114 | + | badge frect // the mode chips together, top-left | |
| 115 | + | modes [2]frect // SHOT, REC; only the current one without Tab | |
| 116 | + | target frect // the target chips together | |
| 117 | + | kinds [3]frect // region, monitor, workspace | |
| 118 | + | // Chip classes: shot or rec for the active chip, idle, or off for a | |
| 119 | + | // workspace this output does not offer. | |
| 120 | + | modeState [2]string | |
| 121 | + | kindState [3]string | |
| 122 | + | toast frect | |
| 123 | + | footer frect | |
| 95 | 124 | } | |
| 96 | 125 | ||
| 97 | 126 | // highlight returns the rectangle to outline and whether the rest is dimmed. | |
| @@ -130,11 +159,12 @@ func (m *model) scene() scene { | |||
| 130 | 159 | s.dim[0] = frect{0, 0, w, h} | |
| 131 | 160 | } | |
| 132 | 161 | if outline { | |
| 162 | + | e := min(max(m.edgeW, edge), fr.w/2, fr.h/2) | |
| 133 | 163 | s.edges = [4]frect{ | |
| 134 | - | {fr.x, fr.y, fr.w, edge}, | |
| 135 | - | {fr.x, fr.y + fr.h - edge, fr.w, edge}, | |
| 136 | - | {fr.x, fr.y, edge, fr.h}, | |
| 137 | - | {fr.x + fr.w - edge, fr.y, edge, fr.h}, | |
| 164 | + | {fr.x, fr.y, fr.w, e}, | |
| 165 | + | {fr.x, fr.y + fr.h - e, fr.w, e}, | |
| 166 | + | {fr.x, fr.y, e, fr.h}, | |
| 167 | + | {fr.x + fr.w - e, fr.y, e, fr.h}, | |
| 138 | 168 | } | |
| 139 | 169 | } | |
| 140 | 170 | if m.grid { | |
| @@ -145,7 +175,8 @@ func (m *model) scene() scene { | |||
| 145 | 175 | } | |
| 146 | 176 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | |
| 147 | 177 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | |
| 148 | - | s.placeBadge(m.mode, w, h) | |
| 178 | + | s.placeChips(m, w, h) | |
| 179 | + | s.chipStates(m) | |
| 149 | 180 | // Keep workspace dimensions clear of the output header; region and | |
| 150 | 181 | // monitor labels retain their independent placement. | |
| 151 | 182 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | |
| @@ -160,26 +191,112 @@ func (m *model) scene() scene { | |||
| 160 | 191 | fw := min(footerW, w) | |
| 161 | 192 | fh := min(footerHeight(m.footerText, fw), h) | |
| 162 | 193 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | |
| 194 | + | if m.toast.on { | |
| 195 | + | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | |
| 196 | + | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | |
| 197 | + | } | |
| 163 | 198 | return s | |
| 164 | 199 | } | |
| 165 | 200 | ||
| 166 | - | // placeBadge puts the mode badge top-left; on an output too narrow to share | |
| 167 | - | // the row with the header it moves below it. | |
| 168 | - | func (s *scene) placeBadge(mode ports.Mode, w, h float64) { | |
| 169 | - | bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h) | |
| 170 | - | s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh} | |
| 171 | - | if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h { | |
| 172 | - | s.badge.y = s.header.y + s.header.h + tagPadY | |
| 201 | + | // placeChips puts the mode chips top-left and the target chips after them, | |
| 202 | + | // or below them when the row would reach the output header. On an output too | |
| 203 | + | // narrow to share the row with the header, both move below it. | |
| 204 | + | func (s *scene) placeChips(m *model, w, h float64) { | |
| 205 | + | ch := min(headerH, h) | |
| 206 | + | x := 0.0 | |
| 207 | + | for i, mode := range modeChips { | |
| 208 | + | if !m.toggle && mode != m.mode { | |
| 209 | + | continue | |
| 210 | + | } | |
| 211 | + | cw := headerWidth(badgeText(mode)) | |
| 212 | + | if x > 0 { | |
| 213 | + | x += chipGap | |
| 214 | + | } | |
| 215 | + | s.modes[i] = frect{x, 0, cw, ch} | |
| 216 | + | x += cw | |
| 217 | + | } | |
| 218 | + | s.badge = frect{0, 0, x, ch} | |
| 219 | + | x = 0 | |
| 220 | + | for i, text := range targetTexts { | |
| 221 | + | cw := headerWidth(text) | |
| 222 | + | if x > 0 { | |
| 223 | + | x += chipGap | |
| 224 | + | } | |
| 225 | + | s.kinds[i] = frect{x, 0, cw, ch} | |
| 226 | + | x += cw | |
| 227 | + | } | |
| 228 | + | s.target = frect{0, 0, x, ch} | |
| 229 | + | ||
| 230 | + | top := min(margin, max(h-ch, 0)) | |
| 231 | + | left := min(margin, max(w-s.badge.w, 0)) | |
| 232 | + | row := frect{left, top, s.badge.w + groupGap + s.target.w, ch} | |
| 233 | + | below := overlaps(row, s.header) || row.x+row.w > w | |
| 234 | + | if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h { | |
| 235 | + | top = s.header.y + s.header.h + tagPadY | |
| 236 | + | row.y = top | |
| 237 | + | below = row.x+row.w > w | |
| 238 | + | } | |
| 239 | + | s.badge.x, s.badge.y = left, top | |
| 240 | + | s.target.x, s.target.y = left+s.badge.w+groupGap, top | |
| 241 | + | if below { | |
| 242 | + | s.target.x, s.target.y = left, top+ch+tagPadY | |
| 243 | + | } | |
| 244 | + | for i := range s.modes { | |
| 245 | + | place(&s.modes[i], s.badge, w, h) | |
| 173 | 246 | } | |
| 247 | + | for i := range s.kinds { | |
| 248 | + | place(&s.kinds[i], s.target, w, h) | |
| 249 | + | } | |
| 250 | + | s.badge.w = min(s.badge.w, max(w-s.badge.x, 0)) | |
| 251 | + | s.target.w = min(s.target.w, max(w-s.target.x, 0)) | |
| 174 | 252 | s.clearLabelOfBadge(frect{}, h) | |
| 175 | 253 | } | |
| 176 | 254 | ||
| 177 | - | // clearLabelOfBadge moves a size label that meets the badge below it, inside | |
| 178 | - | // the highlighted rectangle fr when known. | |
| 255 | + | // chipStates colors the current mode and target with the mode class. | |
| 256 | + | func (s *scene) chipStates(m *model) { | |
| 257 | + | mc := modeClass(m.mode) | |
| 258 | + | for i, mode := range modeChips { | |
| 259 | + | s.modeState[i] = "idle" | |
| 260 | + | if mode == m.mode { | |
| 261 | + | s.modeState[i] = mc | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | _, _, workspace := m.picker.Workspace() | |
| 265 | + | for i, kind := range targetKinds { | |
| 266 | + | switch { | |
| 267 | + | case kind == m.picker.Kind(): | |
| 268 | + | s.kindState[i] = mc | |
| 269 | + | case kind == core.PickWorkspace && !workspace: | |
| 270 | + | s.kindState[i] = "off" | |
| 271 | + | default: | |
| 272 | + | s.kindState[i] = "idle" | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // place moves a chip laid out from the origin into its group and clips it to | |
| 278 | + | // the surface; a chip with no room left gets no area and is not drawn. | |
| 279 | + | func place(c *frect, group frect, w, h float64) { | |
| 280 | + | if c.w == 0 { | |
| 281 | + | return | |
| 282 | + | } | |
| 283 | + | c.x += group.x | |
| 284 | + | c.y = group.y | |
| 285 | + | c.w = min(c.w, max(w-c.x, 0)) | |
| 286 | + | c.h = min(c.h, max(h-c.y, 0)) | |
| 287 | + | if c.w <= 0 || c.h <= 0 { | |
| 288 | + | *c = frect{} | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // clearLabelOfBadge moves a size label that meets the chips below them, | |
| 293 | + | // inside the highlighted rectangle fr when known. | |
| 179 | 294 | func (s *scene) clearLabelOfBadge(fr frect, h float64) { | |
| 180 | - | if s.label.w > 0 && overlaps(s.label, s.badge) { | |
| 181 | - | if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h { | |
| 182 | - | s.label.y = y | |
| 295 | + | for _, r := range [2]frect{s.badge, s.target} { | |
| 296 | + | if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) { | |
| 297 | + | if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h { | |
| 298 | + | s.label.y = y | |
| 299 | + | } | |
| 183 | 300 | } | |
| 184 | 301 | } | |
| 185 | 302 | } | |
| @@ -208,7 +325,12 @@ func footerHeight(text string, width float64) float64 { | |||
| 208 | 325 | ||
| 209 | 326 | // headerWidth is a monospace estimate of the header text plus padding. | |
| 210 | 327 | func headerWidth(text string) float64 { | |
| 211 | - | return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX | |
| 328 | + | return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // toastWidth is the same estimate at the toast's larger font. | |
| 332 | + | func toastWidth(text string) float64 { | |
| 333 | + | return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX | |
| 212 | 334 | } | |
| 213 | 335 | ||
| 214 | 336 | // labelRect places the size label above the region, or just inside it when | |
| @@ -2,6 +2,7 @@ package selection | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | + | "unicode/utf8" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/nefercap/internal/core" | 7 | "github.com/bnema/nefercap/internal/core" | ||
| 7 | "github.com/bnema/nefercap/internal/ports" | 8 | "github.com/bnema/nefercap/internal/ports" | ||
| @@ -18,6 +19,10 @@ const ( | |||||
| 18 | headerH = labelH | 19 | headerH = labelH | ||
| 19 | edge = 1.0 // thin selection lines | 20 | edge = 1.0 // thin selection lines | ||
| 20 | margin = baseFont | 21 | margin = baseFont | ||
| 22 | + | chipGap = baseFont * 0.25 // between chips of one group | |||
| 23 | + | groupGap = baseFont * 0.75 // between the mode and target groups | |||
| 24 | + | toastFont = baseFont * 1.5 | |||
| 25 | + | toastH = toastFont * 2 | |||
| 21 | ) | 26 | ) | ||
| 22 | 27 | ||||
| 23 | // noIndicatorHint is shown in record mode on a compositor that cannot keep a | 28 | // noIndicatorHint is shown in record mode on a compositor that cannot keep a | ||
| @@ -69,8 +74,8 @@ func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndica | |||||
| 69 | return b.String() | 74 | return b.String() | ||
| 70 | } | 75 | } | ||
| 71 | 76 | ||||
| 72 | - | // badgeText is the always-visible mode badge. Both modes share the neutral | 77 | + | // badgeText is the label of a mode chip. No mode is red: red would read as |
| 73 | - | // tag style: a red badge would read as an active recording. | 78 | + | // an active recording. |
| 74 | func badgeText(mode ports.Mode) string { | 79 | func badgeText(mode ports.Mode) string { | ||
| 75 | if mode == ports.Record { | 80 | if mode == ports.Record { | ||
| 76 | return "REC" | 81 | return "REC" | ||
| @@ -78,6 +83,22 @@ func badgeText(mode ports.Mode) string { | |||||
| 78 | return "SHOT" | 83 | return "SHOT" | ||
| 79 | } | 84 | } | ||
| 80 | 85 | ||||
| 86 | + | // modeClass is the stylesheet class that colors the active mode: cyan for | |||
| 87 | + | // screenshots, amber for recordings. | |||
| 88 | + | func modeClass(mode ports.Mode) string { | |||
| 89 | + | if mode == ports.Record { | |||
| 90 | + | return "rec" | |||
| 91 | + | } | |||
| 92 | + | return "shot" | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Chip labels: the key, then the target. Indexes match targetKinds. | |||
| 96 | + | var ( | |||
| 97 | + | modeChips = [2]ports.Mode{ports.Screenshot, ports.Record} | |||
| 98 | + | targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace} | |||
| 99 | + | targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"} | |||
| 100 | + | ) | |||
| 101 | + | ||||
| 81 | // frect is a float rectangle in surface-local logical pixels, the unit | 102 | // frect is a float rectangle in surface-local logical pixels, the unit | ||
| 82 | // Node.Rect takes. | 103 | // Node.Rect takes. | ||
| 83 | type frect struct{ x, y, w, h float64 } | 104 | type frect struct{ x, y, w, h float64 } | ||
| @@ -90,8 +111,16 @@ type scene struct { | |||||
| 90 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | 111 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | ||
| 91 | label frect | 112 | label frect | ||
| 92 | header frect | 113 | header frect | ||
| 93 | - | badge frect // top-left mode badge | 114 | + | badge frect // the mode chips together, top-left |
| 94 | - | footer frect | 115 | + | modes [2]frect // SHOT, REC; only the current one without Tab |
| 116 | + | target frect // the target chips together | |||
| 117 | + | kinds [3]frect // region, monitor, workspace | |||
| 118 | + | // Chip classes: shot or rec for the active chip, idle, or off for a | |||
| 119 | + | // workspace this output does not offer. | |||
| 120 | + | modeState [2]string | |||
| 121 | + | kindState [3]string | |||
| 122 | + | toast frect | |||
| 123 | + | footer frect | |||
| 95 | } | 124 | } | ||
| 96 | 125 | ||||
| 97 | // highlight returns the rectangle to outline and whether the rest is dimmed. | 126 | // highlight returns the rectangle to outline and whether the rest is dimmed. | ||
| @@ -130,11 +159,12 @@ func (m *model) scene() scene { | |||||
| 130 | s.dim[0] = frect{0, 0, w, h} | 159 | s.dim[0] = frect{0, 0, w, h} | ||
| 131 | } | 160 | } | ||
| 132 | if outline { | 161 | if outline { | ||
| 162 | + | e := min(max(m.edgeW, edge), fr.w/2, fr.h/2) | |||
| 133 | s.edges = [4]frect{ | 163 | s.edges = [4]frect{ | ||
| 134 | - | {fr.x, fr.y, fr.w, edge}, | 164 | + | {fr.x, fr.y, fr.w, e}, |
| 135 | - | {fr.x, fr.y + fr.h - edge, fr.w, edge}, | 165 | + | {fr.x, fr.y + fr.h - e, fr.w, e}, |
| 136 | - | {fr.x, fr.y, edge, fr.h}, | 166 | + | {fr.x, fr.y, e, fr.h}, |
| 137 | - | {fr.x + fr.w - edge, fr.y, edge, fr.h}, | 167 | + | {fr.x + fr.w - e, fr.y, e, fr.h}, |
| 138 | } | 168 | } | ||
| 139 | } | 169 | } | ||
| 140 | if m.grid { | 170 | if m.grid { | ||
| @@ -145,7 +175,8 @@ func (m *model) scene() scene { | |||||
| 145 | } | 175 | } | ||
| 146 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | 176 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | ||
| 147 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | 177 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | ||
| 148 | - | s.placeBadge(m.mode, w, h) | 178 | + | s.placeChips(m, w, h) |
| 179 | + | s.chipStates(m) | |||
| 149 | // Keep workspace dimensions clear of the output header; region and | 180 | // Keep workspace dimensions clear of the output header; region and | ||
| 150 | // monitor labels retain their independent placement. | 181 | // monitor labels retain their independent placement. | ||
| 151 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | 182 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | ||
| @@ -160,26 +191,112 @@ func (m *model) scene() scene { | |||||
| 160 | fw := min(footerW, w) | 191 | fw := min(footerW, w) | ||
| 161 | fh := min(footerHeight(m.footerText, fw), h) | 192 | fh := min(footerHeight(m.footerText, fw), h) | ||
| 162 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | 193 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | ||
| 194 | + | if m.toast.on { | |||
| 195 | + | tw, th := min(toastWidth(m.toast.text), w), min(toastH, h) | |||
| 196 | + | s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th} | |||
| 197 | + | } | |||
| 163 | return s | 198 | return s | ||
| 164 | } | 199 | } | ||
| 165 | 200 | ||||
| 166 | - | // placeBadge puts the mode badge top-left; on an output too narrow to share | 201 | + | // placeChips puts the mode chips top-left and the target chips after them, |
| 167 | - | // the row with the header it moves below it. | 202 | + | // or below them when the row would reach the output header. On an output too |
| 168 | - | func (s *scene) placeBadge(mode ports.Mode, w, h float64) { | 203 | + | // narrow to share the row with the header, both move below it. |
| 169 | - | bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h) | 204 | + | func (s *scene) placeChips(m *model, w, h float64) { |
| 170 | - | s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh} | 205 | + | ch := min(headerH, h) |
| 171 | - | if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h { | 206 | + | x := 0.0 |
| 172 | - | s.badge.y = s.header.y + s.header.h + tagPadY | 207 | + | for i, mode := range modeChips { |
| 208 | + | if !m.toggle && mode != m.mode { | |||
| 209 | + | continue | |||
| 210 | + | } | |||
| 211 | + | cw := headerWidth(badgeText(mode)) | |||
| 212 | + | if x > 0 { | |||
| 213 | + | x += chipGap | |||
| 214 | + | } | |||
| 215 | + | s.modes[i] = frect{x, 0, cw, ch} | |||
| 216 | + | x += cw | |||
| 217 | + | } | |||
| 218 | + | s.badge = frect{0, 0, x, ch} | |||
| 219 | + | x = 0 | |||
| 220 | + | for i, text := range targetTexts { | |||
| 221 | + | cw := headerWidth(text) | |||
| 222 | + | if x > 0 { | |||
| 223 | + | x += chipGap | |||
| 224 | + | } | |||
| 225 | + | s.kinds[i] = frect{x, 0, cw, ch} | |||
| 226 | + | x += cw | |||
| 227 | + | } | |||
| 228 | + | s.target = frect{0, 0, x, ch} | |||
| 229 | + | ||||
| 230 | + | top := min(margin, max(h-ch, 0)) | |||
| 231 | + | left := min(margin, max(w-s.badge.w, 0)) | |||
| 232 | + | row := frect{left, top, s.badge.w + groupGap + s.target.w, ch} | |||
| 233 | + | below := overlaps(row, s.header) || row.x+row.w > w | |||
| 234 | + | if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h { | |||
| 235 | + | top = s.header.y + s.header.h + tagPadY | |||
| 236 | + | row.y = top | |||
| 237 | + | below = row.x+row.w > w | |||
| 238 | + | } | |||
| 239 | + | s.badge.x, s.badge.y = left, top | |||
| 240 | + | s.target.x, s.target.y = left+s.badge.w+groupGap, top | |||
| 241 | + | if below { | |||
| 242 | + | s.target.x, s.target.y = left, top+ch+tagPadY | |||
| 243 | + | } | |||
| 244 | + | for i := range s.modes { | |||
| 245 | + | place(&s.modes[i], s.badge, w, h) | |||
| 173 | } | 246 | } | ||
| 247 | + | for i := range s.kinds { | |||
| 248 | + | place(&s.kinds[i], s.target, w, h) | |||
| 249 | + | } | |||
| 250 | + | s.badge.w = min(s.badge.w, max(w-s.badge.x, 0)) | |||
| 251 | + | s.target.w = min(s.target.w, max(w-s.target.x, 0)) | |||
| 174 | s.clearLabelOfBadge(frect{}, h) | 252 | s.clearLabelOfBadge(frect{}, h) | ||
| 175 | } | 253 | } | ||
| 176 | 254 | ||||
| 177 | - | // clearLabelOfBadge moves a size label that meets the badge below it, inside | 255 | + | // chipStates colors the current mode and target with the mode class. |
| 178 | - | // the highlighted rectangle fr when known. | 256 | + | func (s *scene) chipStates(m *model) { |
| 257 | + | mc := modeClass(m.mode) | |||
| 258 | + | for i, mode := range modeChips { | |||
| 259 | + | s.modeState[i] = "idle" | |||
| 260 | + | if mode == m.mode { | |||
| 261 | + | s.modeState[i] = mc | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | _, _, workspace := m.picker.Workspace() | |||
| 265 | + | for i, kind := range targetKinds { | |||
| 266 | + | switch { | |||
| 267 | + | case kind == m.picker.Kind(): | |||
| 268 | + | s.kindState[i] = mc | |||
| 269 | + | case kind == core.PickWorkspace && !workspace: | |||
| 270 | + | s.kindState[i] = "off" | |||
| 271 | + | default: | |||
| 272 | + | s.kindState[i] = "idle" | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // place moves a chip laid out from the origin into its group and clips it to | |||
| 278 | + | // the surface; a chip with no room left gets no area and is not drawn. | |||
| 279 | + | func place(c *frect, group frect, w, h float64) { | |||
| 280 | + | if c.w == 0 { | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | c.x += group.x | |||
| 284 | + | c.y = group.y | |||
| 285 | + | c.w = min(c.w, max(w-c.x, 0)) | |||
| 286 | + | c.h = min(c.h, max(h-c.y, 0)) | |||
| 287 | + | if c.w <= 0 || c.h <= 0 { | |||
| 288 | + | *c = frect{} | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // clearLabelOfBadge moves a size label that meets the chips below them, | |||
| 293 | + | // inside the highlighted rectangle fr when known. | |||
| 179 | func (s *scene) clearLabelOfBadge(fr frect, h float64) { | 294 | func (s *scene) clearLabelOfBadge(fr frect, h float64) { | ||
| 180 | - | if s.label.w > 0 && overlaps(s.label, s.badge) { | 295 | + | for _, r := range [2]frect{s.badge, s.target} { |
| 181 | - | if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h { | 296 | + | if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) { |
| 182 | - | s.label.y = y | 297 | + | if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h { |
| 298 | + | s.label.y = y | |||
| 299 | + | } | |||
| 183 | } | 300 | } | ||
| 184 | } | 301 | } | ||
| 185 | } | 302 | } | ||
| @@ -208,7 +325,12 @@ func footerHeight(text string, width float64) float64 { | |||||
| 208 | 325 | ||||
| 209 | // headerWidth is a monospace estimate of the header text plus padding. | 326 | // headerWidth is a monospace estimate of the header text plus padding. | ||
| 210 | func headerWidth(text string) float64 { | 327 | func headerWidth(text string) float64 { | ||
| 211 | - | return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX | 328 | + | return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX |
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // toastWidth is the same estimate at the toast's larger font. | |||
| 332 | + | func toastWidth(text string) float64 { | |||
| 333 | + | return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX | |||
| 212 | } | 334 | } | ||
| 213 | 335 | ||||
| 214 | // labelRect places the size label above the region, or just inside it when | 336 | // labelRect places the size label above the region, or just inside it when | ||
M internal/adapters/selection/selection_test.go +12 -7
| @@ -234,8 +234,9 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) { | |||
| 234 | 234 | none, _ := newBareModel() | |
| 235 | 235 | none.resize(1920, 1080, 1) | |
| 236 | 236 | none.input(key("w")) | |
| 237 | - | none.input(key("Return")) | |
| 238 | - | assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing") | |
| 237 | + | assert.Equal(t, core.PickMonitor, none.picker.Kind(), "W without workspace metadata falls back to the monitor") | |
| 238 | + | assert.True(t, none.toast.warn) | |
| 239 | + | assert.Equal(t, toastNoWS, none.toast.text) | |
| 239 | 240 | ||
| 240 | 241 | m, _ := newBareModel() | |
| 241 | 242 | m.setWorkspaces(ws) | |
| @@ -269,7 +270,9 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) { | |||
| 269 | 270 | assert.False(t, ok, "a workspace that is not displayed is never offered") | |
| 270 | 271 | hidden.input(key("w")) | |
| 271 | 272 | hidden.input(key("Return")) | |
| 272 | - | assert.Equal(t, endNone, hidden.end) | |
| 273 | + | require.Equal(t, endAccepted, hidden.end) | |
| 274 | + | res, _, _ := hidden.sess.outcome(context.Background()) | |
| 275 | + | assert.Equal(t, core.PickMonitor, res.Kind, "a hidden workspace is never captured; W falls back to the monitor") | |
| 273 | 276 | } | |
| 274 | 277 | ||
| 275 | 278 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | |
| @@ -440,7 +443,9 @@ func TestSceneGeometry(t *testing.T) { | |||
| 440 | 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | |
| 441 | 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | |
| 442 | 445 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | |
| 443 | - | assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label) | |
| 446 | + | // Above the region the label would meet the chips, so it moves below them. | |
| 447 | + | assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label) | |
| 448 | + | assert.False(t, overlaps(s.label, s.target)) | |
| 444 | 449 | // Dim strips and region tile the output exactly. | |
| 445 | 450 | area := 200.0 * 200.0 | |
| 446 | 451 | for _, d := range s.dim { | |
| @@ -748,7 +753,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |||
| 748 | 753 | repeat.Repeat = true | |
| 749 | 754 | assert.False(t, m.input(repeat), "repeat") | |
| 750 | 755 | assert.False(t, m.input(key("q")), "unknown key") | |
| 751 | - | assert.False(t, m.input(key("w")), "W without a workspace") | |
| 756 | + | assert.True(t, m.input(key("w")), "W without a workspace warns and falls back") | |
| 757 | + | m = newTestModel(t) | |
| 752 | 758 | ||
| 753 | 759 | assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible") | |
| 754 | 760 | assert.False(t, m.input(motion(102, 51)), "below the drag threshold") | |
| @@ -760,9 +766,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |||
| 760 | 766 | m = newTestModel(t) | |
| 761 | 767 | assert.True(t, m.input(key("g")), "grid toggles") | |
| 762 | 768 | assert.True(t, m.input(key("m")), "monitor outline") | |
| 763 | - | assert.False(t, m.input(key("m")), "already chosen") | |
| 769 | + | assert.True(t, m.input(key("m")), "an already chosen target is confirmed again") | |
| 764 | 770 | assert.True(t, m.input(key("r"))) | |
| 765 | - | assert.False(t, m.input(key("r"))) | |
| 766 | 771 | } | |
| 767 | 772 | ||
| 768 | 773 | func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) { | |
| @@ -234,8 +234,9 @@ func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) { | |||||
| 234 | none, _ := newBareModel() | 234 | none, _ := newBareModel() | ||
| 235 | none.resize(1920, 1080, 1) | 235 | none.resize(1920, 1080, 1) | ||
| 236 | none.input(key("w")) | 236 | none.input(key("w")) | ||
| 237 | - | none.input(key("Return")) | 237 | + | assert.Equal(t, core.PickMonitor, none.picker.Kind(), "W without workspace metadata falls back to the monitor") |
| 238 | - | assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing") | 238 | + | assert.True(t, none.toast.warn) |
| 239 | + | assert.Equal(t, toastNoWS, none.toast.text) | |||
| 239 | 240 | ||||
| 240 | m, _ := newBareModel() | 241 | m, _ := newBareModel() | ||
| 241 | m.setWorkspaces(ws) | 242 | m.setWorkspaces(ws) | ||
| @@ -269,7 +270,9 @@ func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) { | |||||
| 269 | assert.False(t, ok, "a workspace that is not displayed is never offered") | 270 | assert.False(t, ok, "a workspace that is not displayed is never offered") | ||
| 270 | hidden.input(key("w")) | 271 | hidden.input(key("w")) | ||
| 271 | hidden.input(key("Return")) | 272 | hidden.input(key("Return")) | ||
| 272 | - | assert.Equal(t, endNone, hidden.end) | 273 | + | require.Equal(t, endAccepted, hidden.end) |
| 274 | + | res, _, _ := hidden.sess.outcome(context.Background()) | |||
| 275 | + | assert.Equal(t, core.PickMonitor, res.Kind, "a hidden workspace is never captured; W falls back to the monitor") | |||
| 273 | } | 276 | } | ||
| 274 | 277 | ||||
| 275 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | 278 | func TestSelectionConversionUsesCoreTarget(t *testing.T) { | ||
| @@ -440,7 +443,9 @@ func TestSceneGeometry(t *testing.T) { | |||||
| 440 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | ||
| 441 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | ||
| 442 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | 445 | assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3]) | ||
| 443 | - | assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label) | 446 | + | // Above the region the label would meet the chips, so it moves below them. |
| 447 | + | assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label) | |||
| 448 | + | assert.False(t, overlaps(s.label, s.target)) | |||
| 444 | // Dim strips and region tile the output exactly. | 449 | // Dim strips and region tile the output exactly. | ||
| 445 | area := 200.0 * 200.0 | 450 | area := 200.0 * 200.0 | ||
| 446 | for _, d := range s.dim { | 451 | for _, d := range s.dim { | ||
| @@ -748,7 +753,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |||||
| 748 | repeat.Repeat = true | 753 | repeat.Repeat = true | ||
| 749 | assert.False(t, m.input(repeat), "repeat") | 754 | assert.False(t, m.input(repeat), "repeat") | ||
| 750 | assert.False(t, m.input(key("q")), "unknown key") | 755 | assert.False(t, m.input(key("q")), "unknown key") | ||
| 751 | - | assert.False(t, m.input(key("w")), "W without a workspace") | 756 | + | assert.True(t, m.input(key("w")), "W without a workspace warns and falls back") |
| 757 | + | m = newTestModel(t) | |||
| 752 | 758 | ||||
| 753 | assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible") | 759 | assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible") | ||
| 754 | assert.False(t, m.input(motion(102, 51)), "below the drag threshold") | 760 | assert.False(t, m.input(motion(102, 51)), "below the drag threshold") | ||
| @@ -760,9 +766,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |||||
| 760 | m = newTestModel(t) | 766 | m = newTestModel(t) | ||
| 761 | assert.True(t, m.input(key("g")), "grid toggles") | 767 | assert.True(t, m.input(key("g")), "grid toggles") | ||
| 762 | assert.True(t, m.input(key("m")), "monitor outline") | 768 | assert.True(t, m.input(key("m")), "monitor outline") | ||
| 763 | - | assert.False(t, m.input(key("m")), "already chosen") | 769 | + | assert.True(t, m.input(key("m")), "an already chosen target is confirmed again") |
| 764 | assert.True(t, m.input(key("r"))) | 770 | assert.True(t, m.input(key("r"))) | ||
| 765 | - | assert.False(t, m.input(key("r"))) | |||
| 766 | } | 771 | } | ||
| 767 | 772 | ||||
| 768 | func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) { | 773 | func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) { | ||
M internal/adapters/selection/selector.go +12 -3
| @@ -169,15 +169,24 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 169 | 169 | m.setWorkspaces(s.workspaces) | |
| 170 | 170 | m.noIndicator = s.noIndicator | |
| 171 | 171 | m.backdrop = s.backdrops[outputs[i].ID] | |
| 172 | + | // Every overlay has a wake: Tab on another overlay and the key | |
| 173 | + | // effects both redraw through it. | |
| 174 | + | m.wake, m.anim = make(chan struct{}, 1), newAnimator() | |
| 172 | 175 | if s.toggle { | |
| 173 | - | m.toggle, m.wake = true, make(chan struct{}, 1) | |
| 176 | + | m.toggle = true | |
| 174 | 177 | sess.wakes = append(sess.wakes, m.wake) | |
| 175 | 178 | } | |
| 176 | 179 | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | |
| 177 | 180 | wg.Add(1) | |
| 178 | 181 | go func() { | |
| 179 | 182 | defer wg.Done() | |
| 180 | - | sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m)) | |
| 183 | + | fx, stop := context.WithCancel(child) | |
| 184 | + | var anim sync.WaitGroup | |
| 185 | + | anim.Go(func() { m.anim.run(fx, m.wake) }) | |
| 186 | + | err := run(child, sheet, m) | |
| 187 | + | stop() | |
| 188 | + | anim.Wait() | |
| 189 | + | sess.runEnded(ctx, outputs[i].Name, err) | |
| 181 | 190 | }() | |
| 182 | 191 | } | |
| 183 | 192 | wg.Wait() | |
| @@ -198,7 +207,7 @@ func run(ctx context.Context, sheet string, m *model) error { | |||
| 198 | 207 | return layerui.Run(ctx, layerui.Config{ | |
| 199 | 208 | Layer: layerConfig(m.outputs[m.index].Name, kb), | |
| 200 | 209 | Styles: sheet, | |
| 201 | - | Wake: m.wake, // nil without toggle: never ready | |
| 210 | + | Wake: m.wake, | |
| 202 | 211 | OnResize: m.resize, | |
| 203 | 212 | OnInput: m.input, | |
| 204 | 213 | }, m, view) | |
| @@ -169,15 +169,24 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 169 | m.setWorkspaces(s.workspaces) | 169 | m.setWorkspaces(s.workspaces) | ||
| 170 | m.noIndicator = s.noIndicator | 170 | m.noIndicator = s.noIndicator | ||
| 171 | m.backdrop = s.backdrops[outputs[i].ID] | 171 | m.backdrop = s.backdrops[outputs[i].ID] | ||
| 172 | + | // Every overlay has a wake: Tab on another overlay and the key | |||
| 173 | + | // effects both redraw through it. | |||
| 174 | + | m.wake, m.anim = make(chan struct{}, 1), newAnimator() | |||
| 172 | if s.toggle { | 175 | if s.toggle { | ||
| 173 | - | m.toggle, m.wake = true, make(chan struct{}, 1) | 176 | + | m.toggle = true |
| 174 | sess.wakes = append(sess.wakes, m.wake) | 177 | sess.wakes = append(sess.wakes, m.wake) | ||
| 175 | } | 178 | } | ||
| 176 | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | 179 | m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator) | ||
| 177 | wg.Add(1) | 180 | wg.Add(1) | ||
| 178 | go func() { | 181 | go func() { | ||
| 179 | defer wg.Done() | 182 | defer wg.Done() | ||
| 180 | - | sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m)) | 183 | + | fx, stop := context.WithCancel(child) |
| 184 | + | var anim sync.WaitGroup | |||
| 185 | + | anim.Go(func() { m.anim.run(fx, m.wake) }) | |||
| 186 | + | err := run(child, sheet, m) | |||
| 187 | + | stop() | |||
| 188 | + | anim.Wait() | |||
| 189 | + | sess.runEnded(ctx, outputs[i].Name, err) | |||
| 181 | }() | 190 | }() | ||
| 182 | } | 191 | } | ||
| 183 | wg.Wait() | 192 | wg.Wait() | ||
| @@ -198,7 +207,7 @@ func run(ctx context.Context, sheet string, m *model) error { | |||||
| 198 | return layerui.Run(ctx, layerui.Config{ | 207 | return layerui.Run(ctx, layerui.Config{ | ||
| 199 | Layer: layerConfig(m.outputs[m.index].Name, kb), | 208 | Layer: layerConfig(m.outputs[m.index].Name, kb), | ||
| 200 | Styles: sheet, | 209 | Styles: sheet, | ||
| 201 | - | Wake: m.wake, // nil without toggle: never ready | 210 | + | Wake: m.wake, |
| 202 | OnResize: m.resize, | 211 | OnResize: m.resize, | ||
| 203 | OnInput: m.input, | 212 | OnInput: m.input, | ||
| 204 | }, m, view) | 213 | }, m, view) | ||
M internal/adapters/selection/style.css +27 -0
| @@ -9,6 +9,10 @@ box.backdrop { overflow: hidden; } | |||
| 9 | 9 | box.backdrop image { width: 100%; height: 100%; } | |
| 10 | 10 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | |
| 11 | 11 | box.edge { background-color: #4fd1a5; } | |
| 12 | + | /* Mode colors: cyan for screenshots, amber for recordings. Never red, which | |
| 13 | + | would read as a recording in progress. */ | |
| 14 | + | box.edge.shot { background-color: #38bdf8; } | |
| 15 | + | box.edge.rec { background-color: #f5a524; } | |
| 12 | 16 | box.grid { background-color: #808080; } | |
| 13 | 17 | box.tag { | |
| 14 | 18 | display: flex; flex-direction: row; justify-content: center; align-items: center; | |
| @@ -16,3 +20,26 @@ box.tag { | |||
| 16 | 20 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | |
| 17 | 21 | font-family: monospace; font-size: 1rem; text-align: center; | |
| 18 | 22 | } | |
| 23 | + | /* Mode and target chips: the active one takes the mode color. */ | |
| 24 | + | box.chip.idle { color: #8b949e; } | |
| 25 | + | box.chip.off { color: #484f58; } | |
| 26 | + | box.chip.shot { background-color: #38bdf8; color: #0d1014; } | |
| 27 | + | box.chip.rec { background-color: #f5a524; color: #0d1014; } | |
| 28 | + | /* Centered toast confirming a key, faded out by the o* steps. */ | |
| 29 | + | box.toast { | |
| 30 | + | display: flex; flex-direction: row; justify-content: center; align-items: center; | |
| 31 | + | box-sizing: border-box; overflow: hidden; | |
| 32 | + | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | |
| 33 | + | font-family: monospace; font-size: 1.5rem; text-align: center; | |
| 34 | + | } | |
| 35 | + | box.toast.warn { color: #f5a524; border: 2px solid #f5a524; } | |
| 36 | + | box.o0 { opacity: 1; } | |
| 37 | + | box.o1 { opacity: 0.9; } | |
| 38 | + | box.o2 { opacity: 0.8; } | |
| 39 | + | box.o3 { opacity: 0.7; } | |
| 40 | + | box.o4 { opacity: 0.6; } | |
| 41 | + | box.o5 { opacity: 0.5; } | |
| 42 | + | box.o6 { opacity: 0.4; } | |
| 43 | + | box.o7 { opacity: 0.3; } | |
| 44 | + | box.o8 { opacity: 0.2; } | |
| 45 | + | box.o9 { opacity: 0.1; } | |
| @@ -9,6 +9,10 @@ box.backdrop { overflow: hidden; } | |||||
| 9 | box.backdrop image { width: 100%; height: 100%; } | 9 | box.backdrop image { width: 100%; height: 100%; } | ||
| 10 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | 10 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | ||
| 11 | box.edge { background-color: #4fd1a5; } | 11 | box.edge { background-color: #4fd1a5; } | ||
| 12 | + | /* Mode colors: cyan for screenshots, amber for recordings. Never red, which | |||
| 13 | + | would read as a recording in progress. */ | |||
| 14 | + | box.edge.shot { background-color: #38bdf8; } | |||
| 15 | + | box.edge.rec { background-color: #f5a524; } | |||
| 12 | box.grid { background-color: #808080; } | 16 | box.grid { background-color: #808080; } | ||
| 13 | box.tag { | 17 | box.tag { | ||
| 14 | display: flex; flex-direction: row; justify-content: center; align-items: center; | 18 | display: flex; flex-direction: row; justify-content: center; align-items: center; | ||
| @@ -16,3 +20,26 @@ box.tag { | |||||
| 16 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | 20 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | ||
| 17 | font-family: monospace; font-size: 1rem; text-align: center; | 21 | font-family: monospace; font-size: 1rem; text-align: center; | ||
| 18 | } | 22 | } | ||
| 23 | + | /* Mode and target chips: the active one takes the mode color. */ | |||
| 24 | + | box.chip.idle { color: #8b949e; } | |||
| 25 | + | box.chip.off { color: #484f58; } | |||
| 26 | + | box.chip.shot { background-color: #38bdf8; color: #0d1014; } | |||
| 27 | + | box.chip.rec { background-color: #f5a524; color: #0d1014; } | |||
| 28 | + | /* Centered toast confirming a key, faded out by the o* steps. */ | |||
| 29 | + | box.toast { | |||
| 30 | + | display: flex; flex-direction: row; justify-content: center; align-items: center; | |||
| 31 | + | box-sizing: border-box; overflow: hidden; | |||
| 32 | + | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | |||
| 33 | + | font-family: monospace; font-size: 1.5rem; text-align: center; | |||
| 34 | + | } | |||
| 35 | + | box.toast.warn { color: #f5a524; border: 2px solid #f5a524; } | |||
| 36 | + | box.o0 { opacity: 1; } | |||
| 37 | + | box.o1 { opacity: 0.9; } | |||
| 38 | + | box.o2 { opacity: 0.8; } | |||
| 39 | + | box.o3 { opacity: 0.7; } | |||
| 40 | + | box.o4 { opacity: 0.6; } | |||
| 41 | + | box.o5 { opacity: 0.5; } | |||
| 42 | + | box.o6 { opacity: 0.4; } | |||
| 43 | + | box.o7 { opacity: 0.3; } | |||
| 44 | + | box.o8 { opacity: 0.2; } | |||
| 45 | + | box.o9 { opacity: 0.1; } | |||
M internal/adapters/selection/view.go +30 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package selection | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/nefergui" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/nefercap/internal/ports" | |
| @@ -13,6 +15,7 @@ import ( | |||
| 13 | 15 | func view(f *nefergui.Frame, m *model) { | |
| 14 | 16 | m.syncMode() // a Tab on another overlay redraws this one | |
| 15 | 17 | m.refreshFooter() | |
| 18 | + | m.animate(time.Now()) | |
| 16 | 19 | root := f.Root(nefergui.Class("root")) | |
| 17 | 20 | st := root.Stack(nefergui.Class("scene")) | |
| 18 | 21 | if drawsBackdrop(m) { | |
| @@ -27,14 +30,20 @@ func view(f *nefergui.Frame, m *model) { | |||
| 27 | 30 | for i, r := range s.grid { | |
| 28 | 31 | box(st, gridKeys[i], "grid", r) | |
| 29 | 32 | } | |
| 33 | + | mc := modeClass(m.mode) | |
| 30 | 34 | for i, r := range s.edges { | |
| 31 | - | box(st, edgeKeys[i], "edge", r) | |
| 35 | + | if r.w > 0 && r.h > 0 { | |
| 36 | + | st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h) | |
| 37 | + | } | |
| 32 | 38 | } | |
| 33 | 39 | if n := tag(st, "label", s.label); n.ok { | |
| 34 | 40 | n.node.Text(m.label.text) | |
| 35 | 41 | } | |
| 36 | - | if n := tag(st, "badge", s.badge); n.ok { | |
| 37 | - | n.node.Text(badgeText(m.mode)) | |
| 42 | + | for i, r := range s.modes { | |
| 43 | + | chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i])) | |
| 44 | + | } | |
| 45 | + | for i, r := range s.kinds { | |
| 46 | + | chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i]) | |
| 38 | 47 | } | |
| 39 | 48 | if n := tag(st, "header", s.header); n.ok { | |
| 40 | 49 | n.node.Text(m.header) | |
| @@ -42,6 +51,22 @@ func view(f *nefergui.Frame, m *model) { | |||
| 42 | 51 | if n := tag(st, "footer", s.footer); n.ok { | |
| 43 | 52 | n.node.Text(m.footerText) | |
| 44 | 53 | } | |
| 54 | + | if r := s.toast; r.w > 0 && r.h > 0 { | |
| 55 | + | kind := "info" | |
| 56 | + | if m.toast.warn { | |
| 57 | + | kind = "warn" | |
| 58 | + | } | |
| 59 | + | st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind), | |
| 60 | + | nefergui.Class(opacityClasses[m.toast.step])).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // chip draws one selector chip; state is shot or rec (active, in the mode | |
| 65 | + | // color), idle, or off (unavailable). | |
| 66 | + | func chip(st nefergui.Node, key string, r frect, state, text string) { | |
| 67 | + | if r.w > 0 && r.h > 0 { | |
| 68 | + | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state)).Rect(r.x, r.y, r.w, r.h).Text(text) | |
| 69 | + | } | |
| 45 | 70 | } | |
| 46 | 71 | ||
| 47 | 72 | // drawsBackdrop: a screenshot selection shows the frozen screen; a recording | |
| @@ -73,4 +98,6 @@ var ( | |||
| 73 | 98 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | |
| 74 | 99 | gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"} | |
| 75 | 100 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | |
| 101 | + | modeKeys = [2]string{"mode-shot", "mode-rec"} | |
| 102 | + | kindKeys = [3]string{"kind-region", "kind-monitor", "kind-workspace"} | |
| 76 | 103 | ) | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package selection | 1 | package selection | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "time" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/nefergui" | 6 | "github.com/bnema/nefergui" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/nefercap/internal/ports" | 8 | "github.com/bnema/nefercap/internal/ports" | ||
| @@ -13,6 +15,7 @@ import ( | |||||
| 13 | func view(f *nefergui.Frame, m *model) { | 15 | func view(f *nefergui.Frame, m *model) { | ||
| 14 | m.syncMode() // a Tab on another overlay redraws this one | 16 | m.syncMode() // a Tab on another overlay redraws this one | ||
| 15 | m.refreshFooter() | 17 | m.refreshFooter() | ||
| 18 | + | m.animate(time.Now()) | |||
| 16 | root := f.Root(nefergui.Class("root")) | 19 | root := f.Root(nefergui.Class("root")) | ||
| 17 | st := root.Stack(nefergui.Class("scene")) | 20 | st := root.Stack(nefergui.Class("scene")) | ||
| 18 | if drawsBackdrop(m) { | 21 | if drawsBackdrop(m) { | ||
| @@ -27,14 +30,20 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 27 | for i, r := range s.grid { | 30 | for i, r := range s.grid { | ||
| 28 | box(st, gridKeys[i], "grid", r) | 31 | box(st, gridKeys[i], "grid", r) | ||
| 29 | } | 32 | } | ||
| 33 | + | mc := modeClass(m.mode) | |||
| 30 | for i, r := range s.edges { | 34 | for i, r := range s.edges { | ||
| 31 | - | box(st, edgeKeys[i], "edge", r) | 35 | + | if r.w > 0 && r.h > 0 { |
| 36 | + | st.Box(nefergui.Key(edgeKeys[i]), nefergui.Class("edge"), nefergui.Class(mc)).Rect(r.x, r.y, r.w, r.h) | |||
| 37 | + | } | |||
| 32 | } | 38 | } | ||
| 33 | if n := tag(st, "label", s.label); n.ok { | 39 | if n := tag(st, "label", s.label); n.ok { | ||
| 34 | n.node.Text(m.label.text) | 40 | n.node.Text(m.label.text) | ||
| 35 | } | 41 | } | ||
| 36 | - | if n := tag(st, "badge", s.badge); n.ok { | 42 | + | for i, r := range s.modes { |
| 37 | - | n.node.Text(badgeText(m.mode)) | 43 | + | chip(st, modeKeys[i], r, s.modeState[i], badgeText(modeChips[i])) |
| 44 | + | } | |||
| 45 | + | for i, r := range s.kinds { | |||
| 46 | + | chip(st, kindKeys[i], r, s.kindState[i], targetTexts[i]) | |||
| 38 | } | 47 | } | ||
| 39 | if n := tag(st, "header", s.header); n.ok { | 48 | if n := tag(st, "header", s.header); n.ok { | ||
| 40 | n.node.Text(m.header) | 49 | n.node.Text(m.header) | ||
| @@ -42,6 +51,22 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 42 | if n := tag(st, "footer", s.footer); n.ok { | 51 | if n := tag(st, "footer", s.footer); n.ok { | ||
| 43 | n.node.Text(m.footerText) | 52 | n.node.Text(m.footerText) | ||
| 44 | } | 53 | } | ||
| 54 | + | if r := s.toast; r.w > 0 && r.h > 0 { | |||
| 55 | + | kind := "info" | |||
| 56 | + | if m.toast.warn { | |||
| 57 | + | kind = "warn" | |||
| 58 | + | } | |||
| 59 | + | st.Box(nefergui.Key("toast"), nefergui.Class("toast"), nefergui.Class(kind), | |||
| 60 | + | nefergui.Class(opacityClasses[m.toast.step])).Rect(r.x, r.y, r.w, r.h).Text(m.toast.text) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // chip draws one selector chip; state is shot or rec (active, in the mode | |||
| 65 | + | // color), idle, or off (unavailable). | |||
| 66 | + | func chip(st nefergui.Node, key string, r frect, state, text string) { | |||
| 67 | + | if r.w > 0 && r.h > 0 { | |||
| 68 | + | st.Box(nefergui.Key(key), nefergui.Class("tag"), nefergui.Class("chip"), nefergui.Class(state)).Rect(r.x, r.y, r.w, r.h).Text(text) | |||
| 69 | + | } | |||
| 45 | } | 70 | } | ||
| 46 | 71 | ||||
| 47 | // drawsBackdrop: a screenshot selection shows the frozen screen; a recording | 72 | // drawsBackdrop: a screenshot selection shows the frozen screen; a recording | ||
| @@ -73,4 +98,6 @@ var ( | |||||
| 73 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | 98 | dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"} | ||
| 74 | gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"} | 99 | gridKeys = [6]string{"grid0", "grid1", "grid2", "grid3", "grid4", "grid5"} | ||
| 75 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | 100 | edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"} | ||
| 101 | + | modeKeys = [2]string{"mode-shot", "mode-rec"} | |||
| 102 | + | kindKeys = [3]string{"kind-region", "kind-monitor", "kind-workspace"} | |||
| 76 | ) | 103 | ) | ||