feat(selection): add Tab mode toggle and mode badge
- commit
- 11965a527d76bd7f094ac292b32050e71ac8f8f2
- author
- brice · · 2026-09-30 15:52 UTC
- committer
- brice · · 2026-09-30 15:52 UTC
- parent
- 7b74838
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +298 -27
A internal/adapters/selection/mode_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package selection | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/nefercap/internal/core" | |
| 10 | + | "github.com/bnema/nefercap/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model { | |
| 14 | + | t.Helper() | |
| 15 | + | m := newTestModel(t) | |
| 16 | + | m.base, m.mode, m.toggle = mode, mode, toggle | |
| 17 | + | m.footerMode = mode | |
| 18 | + | m.footerText = footerText(mode, core.PickRegion, false, toggle) | |
| 19 | + | return m | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestTabTogglesModeKeepingTarget(t *testing.T) { | |
| 23 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 24 | + | assert.Equal(t, "shot · Tab rec · R region · M monitor · G grid · Esc cancel\ndrag + release capture region · click capture monitor", m.footerText) | |
| 25 | + | assert.Equal(t, "SHOT", badgeText(m.mode)) | |
| 26 | + | m.input(key("m")) | |
| 27 | + | require.Equal(t, core.PickMonitor, m.picker.Kind()) | |
| 28 | + | assert.True(t, m.input(key("Tab")), "a mode change redraws") | |
| 29 | + | assert.Equal(t, ports.Record, m.mode) | |
| 30 | + | assert.Equal(t, core.PickMonitor, m.picker.Kind(), "target is kept") | |
| 31 | + | assert.Equal(t, "rec · Tab shot · R region · M monitor · G grid · Esc cancel\nEnter record monitor · click record monitor", m.footerText) | |
| 32 | + | assert.Equal(t, "● REC", badgeText(m.mode)) | |
| 33 | + | m.input(key("Tab")) | |
| 34 | + | assert.Equal(t, ports.Screenshot, m.mode) | |
| 35 | + | assert.Contains(t, m.footerText, "Enter capture monitor") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestTabIgnoredWhenModeFixed(t *testing.T) { | |
| 39 | + | for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} { | |
| 40 | + | m := toggleModel(t, mode, false) | |
| 41 | + | before := m.footerText | |
| 42 | + | assert.False(t, m.input(key("Tab"))) | |
| 43 | + | assert.Equal(t, mode, m.mode) | |
| 44 | + | assert.Equal(t, before, m.footerText) | |
| 45 | + | assert.NotContains(t, m.footerText, "Tab") | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestTabSharedAcrossOverlaysAndResult(t *testing.T) { | |
| 50 | + | sess, _ := testSession() | |
| 51 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |
| 52 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |
| 53 | + | a.toggle, b.toggle = true, true | |
| 54 | + | a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1) | |
| 55 | + | sess.wakes = []chan struct{}{a.wake, b.wake} | |
| 56 | + | a.resize(1920, 1080, 1) | |
| 57 | + | b.resize(1280, 720, 1) | |
| 58 | + | a.input(key("Tab")) | |
| 59 | + | require.Len(t, b.wake, 1, "the other overlay is woken") | |
| 60 | + | b.input(motion(1, 1)) | |
| 61 | + | assert.Equal(t, ports.Record, b.mode) | |
| 62 | + | assert.Contains(t, b.footerText, "rec · Tab shot") | |
| 63 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle() | |
| 64 | + | sel := s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |
| 65 | + | assert.Equal(t, ports.Record, sel.Mode) | |
| 66 | + | assert.Equal(t, DefaultFPS, sel.Video.FPS) | |
| 67 | + | sel = s.selectionFor(ports.Screenshot, core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |
| 68 | + | assert.Equal(t, ports.VideoSettings{}, sel.Video) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestBadgeClassAndText(t *testing.T) { | |
| 72 | + | assert.Equal(t, "SHOT", badgeText(ports.Screenshot)) | |
| 73 | + | assert.Equal(t, "● REC", badgeText(ports.Record)) | |
| 74 | + | assert.Contains(t, styleSheet, "box.rec { background-color: #e5484d;") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) { | |
| 78 | + | for _, w := range []int{1920, 640, 320, 200, 100, 40} { | |
| 79 | + | for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} { | |
| 80 | + | m := toggleModel(t, mode, true) | |
| 81 | + | m.resize(w, 480, 1) | |
| 82 | + | s := m.scene() | |
| 83 | + | assert.GreaterOrEqual(t, s.badge.x, 0.0) | |
| 84 | + | assert.LessOrEqual(t, s.badge.x+s.badge.w, float64(w)) | |
| 85 | + | assert.LessOrEqual(t, s.badge.y+s.badge.h, 480.0) | |
| 86 | + | if w >= 640 { | |
| 87 | + | assert.Equal(t, margin, s.badge.x) | |
| 88 | + | assert.Equal(t, margin, s.badge.y) | |
| 89 | + | } | |
| 90 | + | assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w) | |
| 91 | + | m.input(key("m")) | |
| 92 | + | s = m.scene() | |
| 93 | + | assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w) | |
| 94 | + | assert.False(t, overlaps(s.label, s.badge), "label/badge at %d", w) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestBadgeAvoidsWorkspaceLabelAtLeftEdge(t *testing.T) { | |
| 100 | + | m, _ := newBareModel() | |
| 101 | + | m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 0, Width: 300, Height: 400}}}) | |
| 102 | + | m.resize(640, 480, 1) | |
| 103 | + | m.input(key("w")) | |
| 104 | + | s := m.scene() | |
| 105 | + | assert.False(t, overlaps(s.label, s.badge)) | |
| 106 | + | assert.False(t, overlaps(s.label, s.header)) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestModeToggleAllocations(t *testing.T) { | |
| 110 | + | if raceEnabled { | |
| 111 | + | t.Skip("the race detector adds allocations") | |
| 112 | + | } | |
| 113 | + | m := toggleModel(t, ports.Screenshot, true) | |
| 114 | + | m.input(key("m")) | |
| 115 | + | assert.Zero(t, testing.AllocsPerRun(100, func() { | |
| 116 | + | m.input(motion(10, 10)) | |
| 117 | + | _ = m.scene() | |
| 118 | + | _ = badgeText(m.mode) | |
| 119 | + | }), "unchanged mode allocates nothing") | |
| 120 | + | n := testing.AllocsPerRun(100, func() { | |
| 121 | + | m.input(key("Tab")) | |
| 122 | + | _ = m.scene() | |
| 123 | + | }) | |
| 124 | + | t.Logf("Tab toggle allocs/run: %v", n) | |
| 125 | + | assert.LessOrEqual(t, n, 1.0, "a toggle allocates only the rebuilt legend text") | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package selection | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/nefercap/internal/core" | |||
| 10 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func toggleModel(t *testing.T, mode ports.Mode, toggle bool) *model { | |||
| 14 | + | t.Helper() | |||
| 15 | + | m := newTestModel(t) | |||
| 16 | + | m.base, m.mode, m.toggle = mode, mode, toggle | |||
| 17 | + | m.footerMode = mode | |||
| 18 | + | m.footerText = footerText(mode, core.PickRegion, false, toggle) | |||
| 19 | + | return m | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestTabTogglesModeKeepingTarget(t *testing.T) { | |||
| 23 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 24 | + | assert.Equal(t, "shot · Tab rec · R region · M monitor · G grid · Esc cancel\ndrag + release capture region · click capture monitor", m.footerText) | |||
| 25 | + | assert.Equal(t, "SHOT", badgeText(m.mode)) | |||
| 26 | + | m.input(key("m")) | |||
| 27 | + | require.Equal(t, core.PickMonitor, m.picker.Kind()) | |||
| 28 | + | assert.True(t, m.input(key("Tab")), "a mode change redraws") | |||
| 29 | + | assert.Equal(t, ports.Record, m.mode) | |||
| 30 | + | assert.Equal(t, core.PickMonitor, m.picker.Kind(), "target is kept") | |||
| 31 | + | assert.Equal(t, "rec · Tab shot · R region · M monitor · G grid · Esc cancel\nEnter record monitor · click record monitor", m.footerText) | |||
| 32 | + | assert.Equal(t, "● REC", badgeText(m.mode)) | |||
| 33 | + | m.input(key("Tab")) | |||
| 34 | + | assert.Equal(t, ports.Screenshot, m.mode) | |||
| 35 | + | assert.Contains(t, m.footerText, "Enter capture monitor") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestTabIgnoredWhenModeFixed(t *testing.T) { | |||
| 39 | + | for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} { | |||
| 40 | + | m := toggleModel(t, mode, false) | |||
| 41 | + | before := m.footerText | |||
| 42 | + | assert.False(t, m.input(key("Tab"))) | |||
| 43 | + | assert.Equal(t, mode, m.mode) | |||
| 44 | + | assert.Equal(t, before, m.footerText) | |||
| 45 | + | assert.NotContains(t, m.footerText, "Tab") | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestTabSharedAcrossOverlaysAndResult(t *testing.T) { | |||
| 50 | + | sess, _ := testSession() | |||
| 51 | + | a := newModel(ports.Screenshot, testOutputs, 0, sess, false) | |||
| 52 | + | b := newModel(ports.Screenshot, testOutputs, 1, sess, false) | |||
| 53 | + | a.toggle, b.toggle = true, true | |||
| 54 | + | a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1) | |||
| 55 | + | sess.wakes = []chan struct{}{a.wake, b.wake} | |||
| 56 | + | a.resize(1920, 1080, 1) | |||
| 57 | + | b.resize(1280, 720, 1) | |||
| 58 | + | a.input(key("Tab")) | |||
| 59 | + | require.Len(t, b.wake, 1, "the other overlay is woken") | |||
| 60 | + | b.input(motion(1, 1)) | |||
| 61 | + | assert.Equal(t, ports.Record, b.mode) | |||
| 62 | + | assert.Contains(t, b.footerText, "rec · Tab shot") | |||
| 63 | + | s := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle() | |||
| 64 | + | sel := s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |||
| 65 | + | assert.Equal(t, ports.Record, sel.Mode) | |||
| 66 | + | assert.Equal(t, DefaultFPS, sel.Video.FPS) | |||
| 67 | + | sel = s.selectionFor(ports.Screenshot, core.PickResult{OutputID: 7, Kind: core.PickMonitor}) | |||
| 68 | + | assert.Equal(t, ports.VideoSettings{}, sel.Video) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestBadgeClassAndText(t *testing.T) { | |||
| 72 | + | assert.Equal(t, "SHOT", badgeText(ports.Screenshot)) | |||
| 73 | + | assert.Equal(t, "● REC", badgeText(ports.Record)) | |||
| 74 | + | assert.Contains(t, styleSheet, "box.rec { background-color: #e5484d;") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) { | |||
| 78 | + | for _, w := range []int{1920, 640, 320, 200, 100, 40} { | |||
| 79 | + | for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} { | |||
| 80 | + | m := toggleModel(t, mode, true) | |||
| 81 | + | m.resize(w, 480, 1) | |||
| 82 | + | s := m.scene() | |||
| 83 | + | assert.GreaterOrEqual(t, s.badge.x, 0.0) | |||
| 84 | + | assert.LessOrEqual(t, s.badge.x+s.badge.w, float64(w)) | |||
| 85 | + | assert.LessOrEqual(t, s.badge.y+s.badge.h, 480.0) | |||
| 86 | + | if w >= 640 { | |||
| 87 | + | assert.Equal(t, margin, s.badge.x) | |||
| 88 | + | assert.Equal(t, margin, s.badge.y) | |||
| 89 | + | } | |||
| 90 | + | assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w) | |||
| 91 | + | m.input(key("m")) | |||
| 92 | + | s = m.scene() | |||
| 93 | + | assert.False(t, overlaps(s.badge, s.header), "badge/header at %d", w) | |||
| 94 | + | assert.False(t, overlaps(s.label, s.badge), "label/badge at %d", w) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestBadgeAvoidsWorkspaceLabelAtLeftEdge(t *testing.T) { | |||
| 100 | + | m, _ := newBareModel() | |||
| 101 | + | m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 0, Width: 300, Height: 400}}}) | |||
| 102 | + | m.resize(640, 480, 1) | |||
| 103 | + | m.input(key("w")) | |||
| 104 | + | s := m.scene() | |||
| 105 | + | assert.False(t, overlaps(s.label, s.badge)) | |||
| 106 | + | assert.False(t, overlaps(s.label, s.header)) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestModeToggleAllocations(t *testing.T) { | |||
| 110 | + | if raceEnabled { | |||
| 111 | + | t.Skip("the race detector adds allocations") | |||
| 112 | + | } | |||
| 113 | + | m := toggleModel(t, ports.Screenshot, true) | |||
| 114 | + | m.input(key("m")) | |||
| 115 | + | assert.Zero(t, testing.AllocsPerRun(100, func() { | |||
| 116 | + | m.input(motion(10, 10)) | |||
| 117 | + | _ = m.scene() | |||
| 118 | + | _ = badgeText(m.mode) | |||
| 119 | + | }), "unchanged mode allocates nothing") | |||
| 120 | + | n := testing.AllocsPerRun(100, func() { | |||
| 121 | + | m.input(key("Tab")) | |||
| 122 | + | _ = m.scene() | |||
| 123 | + | }) | |||
| 124 | + | t.Logf("Tab toggle allocs/run: %v", n) | |||
| 125 | + | assert.LessOrEqual(t, n, 1.0, "a toggle allocates only the rebuilt legend text") | |||
| 126 | + | } | |||
M internal/adapters/selection/model.go +42 -7
| @@ -27,7 +27,11 @@ const ( | |||
| 27 | 27 | // here is locked. The decision leaves through the shared session, never through | |
| 28 | 28 | // this struct. | |
| 29 | 29 | type model struct { | |
| 30 | - | mode ports.Mode | |
| 30 | + | base ports.Mode // mode the selector started in | |
| 31 | + | mode ports.Mode // current mode: base flipped by the session's Tab toggles | |
| 32 | + | toggle bool // Tab switches shot <-> rec (all-in-one only) | |
| 33 | + | seen uint32 // session toggles already applied to mode | |
| 34 | + | wake chan struct{} | |
| 31 | 35 | outputs []ports.Output | |
| 32 | 36 | index int // the output this overlay covers; every output has its own model | |
| 33 | 37 | ||
| @@ -48,12 +52,36 @@ type model struct { | |||
| 48 | 52 | header string // static per surface | |
| 49 | 53 | footerText string | |
| 50 | 54 | footerKind core.PickKind | |
| 55 | + | footerMode ports.Mode | |
| 51 | 56 | footerWorkspace bool | |
| 52 | 57 | label label | |
| 53 | 58 | } | |
| 54 | 59 | ||
| 55 | 60 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | |
| 56 | - | return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)} | |
| 61 | + | return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false)} | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // otherMode is the mode a Tab switches to. | |
| 65 | + | func otherMode(mode ports.Mode) ports.Mode { | |
| 66 | + | if mode == ports.Record { | |
| 67 | + | return ports.Screenshot | |
| 68 | + | } | |
| 69 | + | return ports.Record | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // modeAfter is base after n toggles. | |
| 73 | + | func modeAfter(base ports.Mode, n uint32) ports.Mode { | |
| 74 | + | if n%2 == 1 { | |
| 75 | + | return otherMode(base) | |
| 76 | + | } | |
| 77 | + | return base | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // syncMode applies toggles made on any overlay. It never allocates. | |
| 81 | + | func (m *model) syncMode() { | |
| 82 | + | if n := m.sess.toggles.Load(); n != m.seen { | |
| 83 | + | m.seen, m.mode = n, modeAfter(m.base, n) | |
| 84 | + | } | |
| 57 | 85 | } | |
| 58 | 86 | ||
| 59 | 87 | // resize is the OnResize callback. A logical size change (re)starts the | |
| @@ -158,6 +186,7 @@ func (m *model) fail(err error) { | |||
| 158 | 186 | // and never allocates. | |
| 159 | 187 | type visible struct { | |
| 160 | 188 | rect ports.Region | |
| 189 | + | rec bool | |
| 161 | 190 | dragging bool | |
| 162 | 191 | kind core.PickKind | |
| 163 | 192 | grid bool | |
| @@ -166,7 +195,7 @@ type visible struct { | |||
| 166 | 195 | ||
| 167 | 196 | func (m *model) snapshot() visible { | |
| 168 | 197 | r, _ := m.picker.Rect() | |
| 169 | - | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | |
| 198 | + | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | |
| 170 | 199 | } | |
| 171 | 200 | ||
| 172 | 201 | // input is the OnInput callback. It reports whether the drawn state changed, | |
| @@ -178,6 +207,7 @@ func (m *model) input(ev nefergui.InputEvent) bool { | |||
| 178 | 207 | if m.end != endNone || !m.sized { | |
| 179 | 208 | return false | |
| 180 | 209 | } | |
| 210 | + | m.syncMode() | |
| 181 | 211 | before, endBefore := m.snapshot(), m.end | |
| 182 | 212 | switch ev.Kind { | |
| 183 | 213 | case nefergui.InputPointerMotion: | |
| @@ -215,6 +245,11 @@ func (m *model) key(name string) { | |||
| 215 | 245 | m.picker.SelectRegion() | |
| 216 | 246 | case "g", "G": | |
| 217 | 247 | m.grid = !m.grid | |
| 248 | + | case "Tab": | |
| 249 | + | if m.toggle { | |
| 250 | + | m.sess.toggle() | |
| 251 | + | m.syncMode() | |
| 252 | + | } | |
| 218 | 253 | case "Return", "KP_Enter": | |
| 219 | 254 | m.picker.Confirm() | |
| 220 | 255 | default: | |
| @@ -235,13 +270,13 @@ func outputKey(name string, n int) (int, bool) { | |||
| 235 | 270 | return i, i < n | |
| 236 | 271 | } | |
| 237 | 272 | ||
| 238 | - | // refreshFooter rebuilds the footer only when the target kind or workspace changes. | |
| 273 | + | // refreshFooter rebuilds the footer only when the mode, target kind or workspace changes. | |
| 239 | 274 | func (m *model) refreshFooter() { | |
| 240 | 275 | kind := m.picker.Kind() | |
| 241 | 276 | _, _, workspace := m.picker.Workspace() | |
| 242 | - | if kind != m.footerKind || workspace != m.footerWorkspace { | |
| 243 | - | m.footerKind, m.footerWorkspace = kind, workspace | |
| 244 | - | m.footerText = footerText(m.mode, kind, workspace) | |
| 277 | + | if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode { | |
| 278 | + | m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode | |
| 279 | + | m.footerText = footerText(m.mode, kind, workspace, m.toggle) | |
| 245 | 280 | } | |
| 246 | 281 | } | |
| 247 | 282 | ||
| @@ -27,7 +27,11 @@ const ( | |||||
| 27 | // here is locked. The decision leaves through the shared session, never through | 27 | // here is locked. The decision leaves through the shared session, never through | ||
| 28 | // this struct. | 28 | // this struct. | ||
| 29 | type model struct { | 29 | type model struct { | ||
| 30 | - | mode ports.Mode | 30 | + | base ports.Mode // mode the selector started in |
| 31 | + | mode ports.Mode // current mode: base flipped by the session's Tab toggles | |||
| 32 | + | toggle bool // Tab switches shot <-> rec (all-in-one only) | |||
| 33 | + | seen uint32 // session toggles already applied to mode | |||
| 34 | + | wake chan struct{} | |||
| 31 | outputs []ports.Output | 35 | outputs []ports.Output | ||
| 32 | index int // the output this overlay covers; every output has its own model | 36 | index int // the output this overlay covers; every output has its own model | ||
| 33 | 37 | ||||
| @@ -48,12 +52,36 @@ type model struct { | |||||
| 48 | header string // static per surface | 52 | header string // static per surface | ||
| 49 | footerText string | 53 | footerText string | ||
| 50 | footerKind core.PickKind | 54 | footerKind core.PickKind | ||
| 55 | + | footerMode ports.Mode | |||
| 51 | footerWorkspace bool | 56 | footerWorkspace bool | ||
| 52 | label label | 57 | label label | ||
| 53 | } | 58 | } | ||
| 54 | 59 | ||||
| 55 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | 60 | func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model { | ||
| 56 | - | return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)} | 61 | + | return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false)} |
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // otherMode is the mode a Tab switches to. | |||
| 65 | + | func otherMode(mode ports.Mode) ports.Mode { | |||
| 66 | + | if mode == ports.Record { | |||
| 67 | + | return ports.Screenshot | |||
| 68 | + | } | |||
| 69 | + | return ports.Record | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // modeAfter is base after n toggles. | |||
| 73 | + | func modeAfter(base ports.Mode, n uint32) ports.Mode { | |||
| 74 | + | if n%2 == 1 { | |||
| 75 | + | return otherMode(base) | |||
| 76 | + | } | |||
| 77 | + | return base | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // syncMode applies toggles made on any overlay. It never allocates. | |||
| 81 | + | func (m *model) syncMode() { | |||
| 82 | + | if n := m.sess.toggles.Load(); n != m.seen { | |||
| 83 | + | m.seen, m.mode = n, modeAfter(m.base, n) | |||
| 84 | + | } | |||
| 57 | } | 85 | } | ||
| 58 | 86 | ||||
| 59 | // resize is the OnResize callback. A logical size change (re)starts the | 87 | // resize is the OnResize callback. A logical size change (re)starts the | ||
| @@ -158,6 +186,7 @@ func (m *model) fail(err error) { | |||||
| 158 | // and never allocates. | 186 | // and never allocates. | ||
| 159 | type visible struct { | 187 | type visible struct { | ||
| 160 | rect ports.Region | 188 | rect ports.Region | ||
| 189 | + | rec bool | |||
| 161 | dragging bool | 190 | dragging bool | ||
| 162 | kind core.PickKind | 191 | kind core.PickKind | ||
| 163 | grid bool | 192 | grid bool | ||
| @@ -166,7 +195,7 @@ type visible struct { | |||||
| 166 | 195 | ||||
| 167 | func (m *model) snapshot() visible { | 196 | func (m *model) snapshot() visible { | ||
| 168 | r, _ := m.picker.Rect() | 197 | r, _ := m.picker.Rect() | ||
| 169 | - | return visible{r, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} | 198 | + | return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()} |
| 170 | } | 199 | } | ||
| 171 | 200 | ||||
| 172 | // input is the OnInput callback. It reports whether the drawn state changed, | 201 | // input is the OnInput callback. It reports whether the drawn state changed, | ||
| @@ -178,6 +207,7 @@ func (m *model) input(ev nefergui.InputEvent) bool { | |||||
| 178 | if m.end != endNone || !m.sized { | 207 | if m.end != endNone || !m.sized { | ||
| 179 | return false | 208 | return false | ||
| 180 | } | 209 | } | ||
| 210 | + | m.syncMode() | |||
| 181 | before, endBefore := m.snapshot(), m.end | 211 | before, endBefore := m.snapshot(), m.end | ||
| 182 | switch ev.Kind { | 212 | switch ev.Kind { | ||
| 183 | case nefergui.InputPointerMotion: | 213 | case nefergui.InputPointerMotion: | ||
| @@ -215,6 +245,11 @@ func (m *model) key(name string) { | |||||
| 215 | m.picker.SelectRegion() | 245 | m.picker.SelectRegion() | ||
| 216 | case "g", "G": | 246 | case "g", "G": | ||
| 217 | m.grid = !m.grid | 247 | m.grid = !m.grid | ||
| 248 | + | case "Tab": | |||
| 249 | + | if m.toggle { | |||
| 250 | + | m.sess.toggle() | |||
| 251 | + | m.syncMode() | |||
| 252 | + | } | |||
| 218 | case "Return", "KP_Enter": | 253 | case "Return", "KP_Enter": | ||
| 219 | m.picker.Confirm() | 254 | m.picker.Confirm() | ||
| 220 | default: | 255 | default: | ||
| @@ -235,13 +270,13 @@ func outputKey(name string, n int) (int, bool) { | |||||
| 235 | return i, i < n | 270 | return i, i < n | ||
| 236 | } | 271 | } | ||
| 237 | 272 | ||||
| 238 | - | // refreshFooter rebuilds the footer only when the target kind or workspace changes. | 273 | + | // refreshFooter rebuilds the footer only when the mode, target kind or workspace changes. |
| 239 | func (m *model) refreshFooter() { | 274 | func (m *model) refreshFooter() { | ||
| 240 | kind := m.picker.Kind() | 275 | kind := m.picker.Kind() | ||
| 241 | _, _, workspace := m.picker.Workspace() | 276 | _, _, workspace := m.picker.Workspace() | ||
| 242 | - | if kind != m.footerKind || workspace != m.footerWorkspace { | 277 | + | if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode { |
| 243 | - | m.footerKind, m.footerWorkspace = kind, workspace | 278 | + | m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode |
| 244 | - | m.footerText = footerText(m.mode, kind, workspace) | 279 | + | m.footerText = footerText(m.mode, kind, workspace, m.toggle) |
| 245 | } | 280 | } | ||
| 246 | } | 281 | } | ||
| 247 | 282 | ||||
M internal/adapters/selection/scene.go +68 -10
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package selection | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/nefercap/internal/core" | |
| 5 | 7 | "github.com/bnema/nefercap/internal/ports" | |
| 6 | 8 | ) | |
| @@ -20,23 +22,51 @@ const ( | |||
| 20 | 22 | ||
| 21 | 23 | // footerText is rebuilt only when the capture mode, picker kind or workspace | |
| 22 | 24 | // availability changes; pointer motion never formats hints. | |
| 23 | - | func footerText(mode ports.Mode, kind core.PickKind, workspace bool) string { | |
| 24 | - | text, action := "shot · R region · M monitor", "capture" | |
| 25 | + | func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) string { | |
| 26 | + | action, name, other := "capture", "shot", "rec" | |
| 25 | 27 | if mode == ports.Record { | |
| 26 | - | text, action = "rec · R region · M monitor", "record" | |
| 28 | + | action, name, other = "record", "rec", "shot" | |
| 29 | + | } | |
| 30 | + | var b strings.Builder | |
| 31 | + | b.Grow(192) // one allocation for the whole legend | |
| 32 | + | b.WriteString(name) | |
| 33 | + | if toggle { | |
| 34 | + | b.WriteString(" · Tab ") | |
| 35 | + | b.WriteString(other) | |
| 27 | 36 | } | |
| 37 | + | b.WriteString(" · R region · M monitor") | |
| 28 | 38 | if workspace { | |
| 29 | - | text += " · W workspace" | |
| 39 | + | b.WriteString(" · W workspace") | |
| 30 | 40 | } | |
| 31 | - | text += " · G grid · Esc cancel\n" | |
| 41 | + | b.WriteString(" · G grid · Esc cancel\n") | |
| 32 | 42 | switch kind { | |
| 33 | - | case core.PickMonitor: | |
| 34 | - | return text + "Enter " + action + " monitor · click " + action + " monitor" | |
| 35 | - | case core.PickWorkspace: | |
| 36 | - | return text + "Enter " + action + " workspace · click " + action + " workspace" | |
| 43 | + | case core.PickMonitor, core.PickWorkspace: | |
| 44 | + | target := " monitor" | |
| 45 | + | if kind == core.PickWorkspace { | |
| 46 | + | target = " workspace" | |
| 47 | + | } | |
| 48 | + | b.WriteString("Enter ") | |
| 49 | + | b.WriteString(action) | |
| 50 | + | b.WriteString(target) | |
| 51 | + | b.WriteString(" · click ") | |
| 52 | + | b.WriteString(action) | |
| 53 | + | b.WriteString(target) | |
| 37 | 54 | default: | |
| 38 | - | return text + "drag + release " + action + " region · click " + action + " monitor" | |
| 55 | + | b.WriteString("drag + release ") | |
| 56 | + | b.WriteString(action) | |
| 57 | + | b.WriteString(" region · click ") | |
| 58 | + | b.WriteString(action) | |
| 59 | + | b.WriteString(" monitor") | |
| 39 | 60 | } | |
| 61 | + | return b.String() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // badgeText is the always-visible mode badge; recBadge is styled red. | |
| 65 | + | func badgeText(mode ports.Mode) string { | |
| 66 | + | if mode == ports.Record { | |
| 67 | + | return "● REC" | |
| 68 | + | } | |
| 69 | + | return "SHOT" | |
| 40 | 70 | } | |
| 41 | 71 | ||
| 42 | 72 | // frect is a float rectangle in surface-local logical pixels, the unit | |
| @@ -51,6 +81,7 @@ type scene struct { | |||
| 51 | 81 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | |
| 52 | 82 | label frect | |
| 53 | 83 | header frect | |
| 84 | + | badge frect // top-left mode badge | |
| 54 | 85 | footer frect | |
| 55 | 86 | } | |
| 56 | 87 | ||
| @@ -105,6 +136,7 @@ func (m *model) scene() scene { | |||
| 105 | 136 | } | |
| 106 | 137 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | |
| 107 | 138 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | |
| 139 | + | s.placeBadge(m.mode, w, h) | |
| 108 | 140 | // Keep workspace dimensions clear of the output header; region and | |
| 109 | 141 | // monitor labels retain their independent placement. | |
| 110 | 142 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | |
| @@ -115,12 +147,38 @@ func (m *model) scene() scene { | |||
| 115 | 147 | s.label.y = y | |
| 116 | 148 | } | |
| 117 | 149 | } | |
| 150 | + | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | |
| 118 | 151 | fw := min(footerW, w) | |
| 119 | 152 | fh := min(footerHeight(m.footerText, fw), h) | |
| 120 | 153 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | |
| 121 | 154 | return s | |
| 122 | 155 | } | |
| 123 | 156 | ||
| 157 | + | // placeBadge puts the mode badge top-left; on an output too narrow to share | |
| 158 | + | // the row with the header it moves below it. | |
| 159 | + | func (s *scene) placeBadge(mode ports.Mode, w, h float64) { | |
| 160 | + | bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h) | |
| 161 | + | s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh} | |
| 162 | + | if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h { | |
| 163 | + | s.badge.y = s.header.y + s.header.h + tagPadY | |
| 164 | + | } | |
| 165 | + | s.clearLabelOfBadge(frect{}, h) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // clearLabelOfBadge moves a size label that meets the badge below it, inside | |
| 169 | + | // the highlighted rectangle fr when known. | |
| 170 | + | func (s *scene) clearLabelOfBadge(fr frect, h float64) { | |
| 171 | + | if s.label.w > 0 && overlaps(s.label, s.badge) { | |
| 172 | + | if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h { | |
| 173 | + | s.label.y = y | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func overlaps(a, b frect) bool { | |
| 179 | + | return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y | |
| 180 | + | } | |
| 181 | + | ||
| 124 | 182 | // footerHeight counts every explicit row separately, reserving conservative | |
| 125 | 183 | // monospace cells and metric lines for wrapping. Range counts runes (including | |
| 126 | 184 | // the middle-dot separators) without allocating per scene. | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package selection | 1 | package selection | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/nefercap/internal/core" | 6 | "github.com/bnema/nefercap/internal/core" | ||
| 5 | "github.com/bnema/nefercap/internal/ports" | 7 | "github.com/bnema/nefercap/internal/ports" | ||
| 6 | ) | 8 | ) | ||
| @@ -20,23 +22,51 @@ const ( | |||||
| 20 | 22 | ||||
| 21 | // footerText is rebuilt only when the capture mode, picker kind or workspace | 23 | // footerText is rebuilt only when the capture mode, picker kind or workspace | ||
| 22 | // availability changes; pointer motion never formats hints. | 24 | // availability changes; pointer motion never formats hints. | ||
| 23 | - | func footerText(mode ports.Mode, kind core.PickKind, workspace bool) string { | 25 | + | func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle bool) string { |
| 24 | - | text, action := "shot · R region · M monitor", "capture" | 26 | + | action, name, other := "capture", "shot", "rec" |
| 25 | if mode == ports.Record { | 27 | if mode == ports.Record { | ||
| 26 | - | text, action = "rec · R region · M monitor", "record" | 28 | + | action, name, other = "record", "rec", "shot" |
| 29 | + | } | |||
| 30 | + | var b strings.Builder | |||
| 31 | + | b.Grow(192) // one allocation for the whole legend | |||
| 32 | + | b.WriteString(name) | |||
| 33 | + | if toggle { | |||
| 34 | + | b.WriteString(" · Tab ") | |||
| 35 | + | b.WriteString(other) | |||
| 27 | } | 36 | } | ||
| 37 | + | b.WriteString(" · R region · M monitor") | |||
| 28 | if workspace { | 38 | if workspace { | ||
| 29 | - | text += " · W workspace" | 39 | + | b.WriteString(" · W workspace") |
| 30 | } | 40 | } | ||
| 31 | - | text += " · G grid · Esc cancel\n" | 41 | + | b.WriteString(" · G grid · Esc cancel\n") |
| 32 | switch kind { | 42 | switch kind { | ||
| 33 | - | case core.PickMonitor: | 43 | + | case core.PickMonitor, core.PickWorkspace: |
| 34 | - | return text + "Enter " + action + " monitor · click " + action + " monitor" | 44 | + | target := " monitor" |
| 35 | - | case core.PickWorkspace: | 45 | + | if kind == core.PickWorkspace { |
| 36 | - | return text + "Enter " + action + " workspace · click " + action + " workspace" | 46 | + | target = " workspace" |
| 47 | + | } | |||
| 48 | + | b.WriteString("Enter ") | |||
| 49 | + | b.WriteString(action) | |||
| 50 | + | b.WriteString(target) | |||
| 51 | + | b.WriteString(" · click ") | |||
| 52 | + | b.WriteString(action) | |||
| 53 | + | b.WriteString(target) | |||
| 37 | default: | 54 | default: | ||
| 38 | - | return text + "drag + release " + action + " region · click " + action + " monitor" | 55 | + | b.WriteString("drag + release ") |
| 56 | + | b.WriteString(action) | |||
| 57 | + | b.WriteString(" region · click ") | |||
| 58 | + | b.WriteString(action) | |||
| 59 | + | b.WriteString(" monitor") | |||
| 39 | } | 60 | } | ||
| 61 | + | return b.String() | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // badgeText is the always-visible mode badge; recBadge is styled red. | |||
| 65 | + | func badgeText(mode ports.Mode) string { | |||
| 66 | + | if mode == ports.Record { | |||
| 67 | + | return "● REC" | |||
| 68 | + | } | |||
| 69 | + | return "SHOT" | |||
| 40 | } | 70 | } | ||
| 41 | 71 | ||||
| 42 | // frect is a float rectangle in surface-local logical pixels, the unit | 72 | // frect is a float rectangle in surface-local logical pixels, the unit | ||
| @@ -51,6 +81,7 @@ type scene struct { | |||||
| 51 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | 81 | grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal | ||
| 52 | label frect | 82 | label frect | ||
| 53 | header frect | 83 | header frect | ||
| 84 | + | badge frect // top-left mode badge | |||
| 54 | footer frect | 85 | footer frect | ||
| 55 | } | 86 | } | ||
| 56 | 87 | ||||
| @@ -105,6 +136,7 @@ func (m *model) scene() scene { | |||||
| 105 | } | 136 | } | ||
| 106 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | 137 | hw, hh := min(headerWidth(m.header), w), min(headerH, h) | ||
| 107 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | 138 | s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh} | ||
| 139 | + | s.placeBadge(m.mode, w, h) | |||
| 108 | // Keep workspace dimensions clear of the output header; region and | 140 | // Keep workspace dimensions clear of the output header; region and | ||
| 109 | // monitor labels retain their independent placement. | 141 | // monitor labels retain their independent placement. | ||
| 110 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | 142 | if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 && | ||
| @@ -115,12 +147,38 @@ func (m *model) scene() scene { | |||||
| 115 | s.label.y = y | 147 | s.label.y = y | ||
| 116 | } | 148 | } | ||
| 117 | } | 149 | } | ||
| 150 | + | s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it | |||
| 118 | fw := min(footerW, w) | 151 | fw := min(footerW, w) | ||
| 119 | fh := min(footerHeight(m.footerText, fw), h) | 152 | fh := min(footerHeight(m.footerText, fw), h) | ||
| 120 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | 153 | s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh} | ||
| 121 | return s | 154 | return s | ||
| 122 | } | 155 | } | ||
| 123 | 156 | ||||
| 157 | + | // placeBadge puts the mode badge top-left; on an output too narrow to share | |||
| 158 | + | // the row with the header it moves below it. | |||
| 159 | + | func (s *scene) placeBadge(mode ports.Mode, w, h float64) { | |||
| 160 | + | bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h) | |||
| 161 | + | s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh} | |||
| 162 | + | if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h { | |||
| 163 | + | s.badge.y = s.header.y + s.header.h + tagPadY | |||
| 164 | + | } | |||
| 165 | + | s.clearLabelOfBadge(frect{}, h) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // clearLabelOfBadge moves a size label that meets the badge below it, inside | |||
| 169 | + | // the highlighted rectangle fr when known. | |||
| 170 | + | func (s *scene) clearLabelOfBadge(fr frect, h float64) { | |||
| 171 | + | if s.label.w > 0 && overlaps(s.label, s.badge) { | |||
| 172 | + | if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h { | |||
| 173 | + | s.label.y = y | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func overlaps(a, b frect) bool { | |||
| 179 | + | return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y | |||
| 180 | + | } | |||
| 181 | + | ||||
| 124 | // footerHeight counts every explicit row separately, reserving conservative | 182 | // footerHeight counts every explicit row separately, reserving conservative | ||
| 125 | // monospace cells and metric lines for wrapping. Range counts runes (including | 183 | // monospace cells and metric lines for wrapping. Range counts runes (including | ||
| 126 | // the middle-dot separators) without allocating per scene. | 184 | // the middle-dot separators) without allocating per scene. | ||
M internal/adapters/selection/selection_test.go +2 -2
| @@ -632,8 +632,8 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) { | |||
| 632 | 632 | } | |
| 633 | 633 | ||
| 634 | 634 | func TestFooterAndHeaderText(t *testing.T) { | |
| 635 | - | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false), "shot · R region") | |
| 636 | - | assert.Contains(t, footerText(ports.Record, core.PickRegion, false), "rec · R region") | |
| 635 | + | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · R region") | |
| 636 | + | assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false), "rec · R region") | |
| 637 | 637 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | |
| 638 | 638 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | |
| 639 | 639 | i, ok := outputKey("2", 2) | |
| @@ -632,8 +632,8 @@ func TestLabelContentHoldsMonospaceLine(t *testing.T) { | |||||
| 632 | } | 632 | } | ||
| 633 | 633 | ||||
| 634 | func TestFooterAndHeaderText(t *testing.T) { | 634 | func TestFooterAndHeaderText(t *testing.T) { | ||
| 635 | - | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false), "shot · R region") | 635 | + | assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false), "shot · R region") |
| 636 | - | assert.Contains(t, footerText(ports.Record, core.PickRegion, false), "rec · R region") | 636 | + | assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false), "rec · R region") |
| 637 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | 637 | assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0)) | ||
| 638 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | 638 | assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0)) | ||
| 639 | i, ok := outputKey("2", 2) | 639 | i, ok := outputKey("2", 2) | ||
M internal/adapters/selection/selector.go +29 -8
| @@ -49,6 +49,7 @@ type Selector struct { | |||
| 49 | 49 | video ports.VideoSettings | |
| 50 | 50 | workspaces []ports.Workspace | |
| 51 | 51 | grid bool | |
| 52 | + | toggle bool | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | var _ ports.Selector = (*Selector)(nil) | |
| @@ -62,15 +63,19 @@ var _ ports.Selector = (*Selector)(nil) | |||
| 62 | 63 | // Selection.Path. The first output passed to Select opens first, so the caller | |
| 63 | 64 | // orders outputs to put the preferred one first. | |
| 64 | 65 | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector { | |
| 65 | - | if mode == ports.Record && video.FPS == 0 { | |
| 66 | + | if video.FPS == 0 { | |
| 66 | 67 | video.FPS = DefaultFPS | |
| 67 | 68 | } | |
| 68 | - | if mode != ports.Record { | |
| 69 | - | video = ports.VideoSettings{} | |
| 70 | - | } | |
| 71 | 69 | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | |
| 72 | 70 | } | |
| 73 | 71 | ||
| 72 | + | // AllowToggle lets Tab switch between screenshot and record selection. The | |
| 73 | + | // starting mode is New's; the target selection is kept across a switch. | |
| 74 | + | func (s *Selector) AllowToggle() *Selector { | |
| 75 | + | s.toggle = true | |
| 76 | + | return s | |
| 77 | + | } | |
| 78 | + | ||
| 74 | 79 | // Select blocks until the user accepts a selection (true), aborts with Escape | |
| 75 | 80 | // (false, nil) or ctx ends or the window system fails (error). One overlay | |
| 76 | 81 | // opens on every output at once, so the whole desktop is selectable without | |
| @@ -108,6 +113,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 108 | 113 | for i := range outputs { | |
| 109 | 114 | m := newModel(s.mode, outputs, i, sess, s.grid) | |
| 110 | 115 | m.setWorkspaces(s.workspaces) | |
| 116 | + | if s.toggle { | |
| 117 | + | m.toggle, m.wake = true, make(chan struct{}, 1) | |
| 118 | + | sess.wakes = append(sess.wakes, m.wake) | |
| 119 | + | } | |
| 111 | 120 | wg.Add(1) | |
| 112 | 121 | go func() { | |
| 113 | 122 | defer wg.Done() | |
| @@ -119,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||
| 119 | 128 | if err != nil || !ok { | |
| 120 | 129 | return ports.Selection{}, false, err | |
| 121 | 130 | } | |
| 122 | - | return s.selection(result), true, nil | |
| 131 | + | return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil | |
| 123 | 132 | } | |
| 124 | 133 | ||
| 125 | 134 | // run shows one overlay on its own output until the context ends. | |
| @@ -128,7 +137,7 @@ func run(ctx context.Context, sheet string, m *model) error { | |||
| 128 | 137 | if m.index == 0 { | |
| 129 | 138 | kb = nefergui.KeyboardExclusive | |
| 130 | 139 | } | |
| 131 | - | return nefergui.Run(ctx, m, view, | |
| 140 | + | opts := []nefergui.WindowOption{ | |
| 132 | 141 | nefergui.Title("NeferCap selector"), | |
| 133 | 142 | nefergui.Size(1, 1), | |
| 134 | 143 | nefergui.Styles(sheet), | |
| @@ -136,7 +145,11 @@ func run(ctx context.Context, sheet string, m *model) error { | |||
| 136 | 145 | nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)), | |
| 137 | 146 | nefergui.OnResize(m.resize), | |
| 138 | 147 | nefergui.OnInput(m.input), | |
| 139 | - | ) | |
| 148 | + | } | |
| 149 | + | if m.wake != nil { | |
| 150 | + | opts = append(opts, nefergui.Wake(m.wake)) | |
| 151 | + | } | |
| 152 | + | return nefergui.Run(ctx, m, view, opts...) | |
| 140 | 153 | } | |
| 141 | 154 | ||
| 142 | 155 | // layerConfig is one overlay: every edge, above windows, the given keyboard | |
| @@ -157,5 +170,13 @@ func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerCo | |||
| 157 | 170 | // comes live from the compositor, identified by WorkspaceID), a dragged region | |
| 158 | 171 | // keeps its explicit output-local geometry. | |
| 159 | 172 | func (s *Selector) selection(r core.PickResult) ports.Selection { | |
| 160 | - | return ports.Selection{Mode: s.mode, Target: r.Target(), Video: s.video} | |
| 173 | + | return s.selectionFor(s.mode, r) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection { | |
| 177 | + | sel := ports.Selection{Mode: mode, Target: r.Target()} | |
| 178 | + | if mode == ports.Record { | |
| 179 | + | sel.Video = s.video | |
| 180 | + | } | |
| 181 | + | return sel | |
| 161 | 182 | } | |
| @@ -49,6 +49,7 @@ type Selector struct { | |||||
| 49 | video ports.VideoSettings | 49 | video ports.VideoSettings | ||
| 50 | workspaces []ports.Workspace | 50 | workspaces []ports.Workspace | ||
| 51 | grid bool | 51 | grid bool | ||
| 52 | + | toggle bool | |||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
| 54 | var _ ports.Selector = (*Selector)(nil) | 55 | var _ ports.Selector = (*Selector)(nil) | ||
| @@ -62,15 +63,19 @@ var _ ports.Selector = (*Selector)(nil) | |||||
| 62 | // Selection.Path. The first output passed to Select opens first, so the caller | 63 | // Selection.Path. The first output passed to Select opens first, so the caller | ||
| 63 | // orders outputs to put the preferred one first. | 64 | // orders outputs to put the preferred one first. | ||
| 64 | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector { | 65 | func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector { | ||
| 65 | - | if mode == ports.Record && video.FPS == 0 { | 66 | + | if video.FPS == 0 { |
| 66 | video.FPS = DefaultFPS | 67 | video.FPS = DefaultFPS | ||
| 67 | } | 68 | } | ||
| 68 | - | if mode != ports.Record { | |||
| 69 | - | video = ports.VideoSettings{} | |||
| 70 | - | } | |||
| 71 | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | 69 | return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid} | ||
| 72 | } | 70 | } | ||
| 73 | 71 | ||||
| 72 | + | // AllowToggle lets Tab switch between screenshot and record selection. The | |||
| 73 | + | // starting mode is New's; the target selection is kept across a switch. | |||
| 74 | + | func (s *Selector) AllowToggle() *Selector { | |||
| 75 | + | s.toggle = true | |||
| 76 | + | return s | |||
| 77 | + | } | |||
| 78 | + | ||||
| 74 | // Select blocks until the user accepts a selection (true), aborts with Escape | 79 | // Select blocks until the user accepts a selection (true), aborts with Escape | ||
| 75 | // (false, nil) or ctx ends or the window system fails (error). One overlay | 80 | // (false, nil) or ctx ends or the window system fails (error). One overlay | ||
| 76 | // opens on every output at once, so the whole desktop is selectable without | 81 | // opens on every output at once, so the whole desktop is selectable without | ||
| @@ -108,6 +113,10 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 108 | for i := range outputs { | 113 | for i := range outputs { | ||
| 109 | m := newModel(s.mode, outputs, i, sess, s.grid) | 114 | m := newModel(s.mode, outputs, i, sess, s.grid) | ||
| 110 | m.setWorkspaces(s.workspaces) | 115 | m.setWorkspaces(s.workspaces) | ||
| 116 | + | if s.toggle { | |||
| 117 | + | m.toggle, m.wake = true, make(chan struct{}, 1) | |||
| 118 | + | sess.wakes = append(sess.wakes, m.wake) | |||
| 119 | + | } | |||
| 111 | wg.Add(1) | 120 | wg.Add(1) | ||
| 112 | go func() { | 121 | go func() { | ||
| 113 | defer wg.Done() | 122 | defer wg.Done() | ||
| @@ -119,7 +128,7 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se | |||||
| 119 | if err != nil || !ok { | 128 | if err != nil || !ok { | ||
| 120 | return ports.Selection{}, false, err | 129 | return ports.Selection{}, false, err | ||
| 121 | } | 130 | } | ||
| 122 | - | return s.selection(result), true, nil | 131 | + | return s.selectionFor(modeAfter(s.mode, sess.toggles.Load()), result), true, nil |
| 123 | } | 132 | } | ||
| 124 | 133 | ||||
| 125 | // run shows one overlay on its own output until the context ends. | 134 | // run shows one overlay on its own output until the context ends. | ||
| @@ -128,7 +137,7 @@ func run(ctx context.Context, sheet string, m *model) error { | |||||
| 128 | if m.index == 0 { | 137 | if m.index == 0 { | ||
| 129 | kb = nefergui.KeyboardExclusive | 138 | kb = nefergui.KeyboardExclusive | ||
| 130 | } | 139 | } | ||
| 131 | - | return nefergui.Run(ctx, m, view, | 140 | + | opts := []nefergui.WindowOption{ |
| 132 | nefergui.Title("NeferCap selector"), | 141 | nefergui.Title("NeferCap selector"), | ||
| 133 | nefergui.Size(1, 1), | 142 | nefergui.Size(1, 1), | ||
| 134 | nefergui.Styles(sheet), | 143 | nefergui.Styles(sheet), | ||
| @@ -136,7 +145,11 @@ func run(ctx context.Context, sheet string, m *model) error { | |||||
| 136 | nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)), | 145 | nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)), | ||
| 137 | nefergui.OnResize(m.resize), | 146 | nefergui.OnResize(m.resize), | ||
| 138 | nefergui.OnInput(m.input), | 147 | nefergui.OnInput(m.input), | ||
| 139 | - | ) | 148 | + | } |
| 149 | + | if m.wake != nil { | |||
| 150 | + | opts = append(opts, nefergui.Wake(m.wake)) | |||
| 151 | + | } | |||
| 152 | + | return nefergui.Run(ctx, m, view, opts...) | |||
| 140 | } | 153 | } | ||
| 141 | 154 | ||||
| 142 | // layerConfig is one overlay: every edge, above windows, the given keyboard | 155 | // layerConfig is one overlay: every edge, above windows, the given keyboard | ||
| @@ -157,5 +170,13 @@ func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerCo | |||||
| 157 | // comes live from the compositor, identified by WorkspaceID), a dragged region | 170 | // comes live from the compositor, identified by WorkspaceID), a dragged region | ||
| 158 | // keeps its explicit output-local geometry. | 171 | // keeps its explicit output-local geometry. | ||
| 159 | func (s *Selector) selection(r core.PickResult) ports.Selection { | 172 | func (s *Selector) selection(r core.PickResult) ports.Selection { | ||
| 160 | - | return ports.Selection{Mode: s.mode, Target: r.Target(), Video: s.video} | 173 | + | return s.selectionFor(s.mode, r) |
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection { | |||
| 177 | + | sel := ports.Selection{Mode: mode, Target: r.Target()} | |||
| 178 | + | if mode == ports.Record { | |||
| 179 | + | sel.Video = s.video | |||
| 180 | + | } | |||
| 181 | + | return sel | |||
| 161 | } | 182 | } | ||
M internal/adapters/selection/session.go +17 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/nefercap/internal/adapters/uierrors" | |
| 10 | 11 | "github.com/bnema/nefercap/internal/core" | |
| @@ -19,6 +20,11 @@ var errOverlayClosed = errors.New("selection: overlay closed by the compositor") | |||
| 19 | 20 | type session struct { | |
| 20 | 21 | cancel context.CancelFunc | |
| 21 | 22 | ||
| 23 | + | // toggles counts Tab presses on any overlay; wakes redraw every overlay | |
| 24 | + | // after one. wakes is set before the overlays start and never changes. | |
| 25 | + | toggles atomic.Uint32 | |
| 26 | + | wakes []chan struct{} | |
| 27 | + | ||
| 22 | 28 | mu sync.Mutex | |
| 23 | 29 | decided bool | |
| 24 | 30 | accepted bool | |
| @@ -28,6 +34,17 @@ type session struct { | |||
| 28 | 34 | ||
| 29 | 35 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | |
| 30 | 36 | ||
| 37 | + | // toggle flips the capture mode for every overlay and asks each to redraw. | |
| 38 | + | func (s *session) toggle() { | |
| 39 | + | s.toggles.Add(1) | |
| 40 | + | for _, w := range s.wakes { | |
| 41 | + | select { | |
| 42 | + | case w <- struct{}{}: | |
| 43 | + | default: | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 31 | 48 | // accept records the first decision and ends every overlay. | |
| 32 | 49 | func (s *session) accept(r core.PickResult) { | |
| 33 | 50 | s.mu.Lock() | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | + | "sync/atomic" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/nefercap/internal/adapters/uierrors" | 10 | "github.com/bnema/nefercap/internal/adapters/uierrors" | ||
| 10 | "github.com/bnema/nefercap/internal/core" | 11 | "github.com/bnema/nefercap/internal/core" | ||
| @@ -19,6 +20,11 @@ var errOverlayClosed = errors.New("selection: overlay closed by the compositor") | |||||
| 19 | type session struct { | 20 | type session struct { | ||
| 20 | cancel context.CancelFunc | 21 | cancel context.CancelFunc | ||
| 21 | 22 | ||||
| 23 | + | // toggles counts Tab presses on any overlay; wakes redraw every overlay | |||
| 24 | + | // after one. wakes is set before the overlays start and never changes. | |||
| 25 | + | toggles atomic.Uint32 | |||
| 26 | + | wakes []chan struct{} | |||
| 27 | + | ||||
| 22 | mu sync.Mutex | 28 | mu sync.Mutex | ||
| 23 | decided bool | 29 | decided bool | ||
| 24 | accepted bool | 30 | accepted bool | ||
| @@ -28,6 +34,17 @@ type session struct { | |||||
| 28 | 34 | ||||
| 29 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | 35 | func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} } | ||
| 30 | 36 | ||||
| 37 | + | // toggle flips the capture mode for every overlay and asks each to redraw. | |||
| 38 | + | func (s *session) toggle() { | |||
| 39 | + | s.toggles.Add(1) | |||
| 40 | + | for _, w := range s.wakes { | |||
| 41 | + | select { | |||
| 42 | + | case w <- struct{}{}: | |||
| 43 | + | default: | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 31 | // accept records the first decision and ends every overlay. | 48 | // accept records the first decision and ends every overlay. | ||
| 32 | func (s *session) accept(r core.PickResult) { | 49 | func (s *session) accept(r core.PickResult) { | ||
| 33 | s.mu.Lock() | 50 | s.mu.Lock() | ||
M internal/adapters/selection/style.css +2 -0
| @@ -13,3 +13,5 @@ box.tag { | |||
| 13 | 13 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | |
| 14 | 14 | font-family: monospace; font-size: 1rem; text-align: center; | |
| 15 | 15 | } | |
| 16 | + | /* Recording mode badge: the HUD's red. */ | |
| 17 | + | box.rec { background-color: #e5484d; color: #ffffff; } | |
| @@ -13,3 +13,5 @@ box.tag { | |||||
| 13 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | 13 | background-color: rgba(13, 16, 20, 0.92); color: #e6edf3; | ||
| 14 | font-family: monospace; font-size: 1rem; text-align: center; | 14 | font-family: monospace; font-size: 1rem; text-align: center; | ||
| 15 | } | 15 | } | ||
| 16 | + | /* Recording mode badge: the HUD's red. */ | |||
| 17 | + | box.rec { background-color: #e5484d; color: #ffffff; } | |||
M internal/adapters/selection/view.go +12 -0
| @@ -2,6 +2,8 @@ package selection | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefercap/internal/ports" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| 7 | 9 | // view draws the scene. Geometry goes through Node.Rect, so no CSS strings | |
| @@ -9,6 +11,8 @@ import ( | |||
| 9 | 11 | // area are not emitted at all, so an idle surface is one dim box plus the | |
| 10 | 12 | // header and footer, and no empty chip is painted. | |
| 11 | 13 | func view(f *nefergui.Frame, m *model) { | |
| 14 | + | m.syncMode() // a Tab on another overlay redraws this one | |
| 15 | + | m.refreshFooter() | |
| 12 | 16 | root := f.Root(nefergui.Class("root")) | |
| 13 | 17 | st := root.Stack(nefergui.Class("scene")) | |
| 14 | 18 | s := m.scene() | |
| @@ -24,6 +28,14 @@ func view(f *nefergui.Frame, m *model) { | |||
| 24 | 28 | if n := tag(st, "label", s.label); n.ok { | |
| 25 | 29 | n.node.Text(m.label.text) | |
| 26 | 30 | } | |
| 31 | + | if s.badge.w > 0 && s.badge.h > 0 { | |
| 32 | + | class := "tag" | |
| 33 | + | if m.mode == ports.Record { | |
| 34 | + | class = "rec" | |
| 35 | + | } | |
| 36 | + | st.Box(nefergui.Key("badge"), nefergui.Class("tag"), nefergui.Class(class)). | |
| 37 | + | Rect(s.badge.x, s.badge.y, s.badge.w, s.badge.h).Text(badgeText(m.mode)) | |
| 38 | + | } | |
| 27 | 39 | if n := tag(st, "header", s.header); n.ok { | |
| 28 | 40 | n.node.Text(m.header) | |
| 29 | 41 | } | |
| @@ -2,6 +2,8 @@ package selection | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/nefergui" | 4 | "github.com/bnema/nefergui" | ||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 5 | ) | 7 | ) | ||
| 6 | 8 | ||||
| 7 | // view draws the scene. Geometry goes through Node.Rect, so no CSS strings | 9 | // view draws the scene. Geometry goes through Node.Rect, so no CSS strings | ||
| @@ -9,6 +11,8 @@ import ( | |||||
| 9 | // area are not emitted at all, so an idle surface is one dim box plus the | 11 | // area are not emitted at all, so an idle surface is one dim box plus the | ||
| 10 | // header and footer, and no empty chip is painted. | 12 | // header and footer, and no empty chip is painted. | ||
| 11 | func view(f *nefergui.Frame, m *model) { | 13 | func view(f *nefergui.Frame, m *model) { | ||
| 14 | + | m.syncMode() // a Tab on another overlay redraws this one | |||
| 15 | + | m.refreshFooter() | |||
| 12 | root := f.Root(nefergui.Class("root")) | 16 | root := f.Root(nefergui.Class("root")) | ||
| 13 | st := root.Stack(nefergui.Class("scene")) | 17 | st := root.Stack(nefergui.Class("scene")) | ||
| 14 | s := m.scene() | 18 | s := m.scene() | ||
| @@ -24,6 +28,14 @@ func view(f *nefergui.Frame, m *model) { | |||||
| 24 | if n := tag(st, "label", s.label); n.ok { | 28 | if n := tag(st, "label", s.label); n.ok { | ||
| 25 | n.node.Text(m.label.text) | 29 | n.node.Text(m.label.text) | ||
| 26 | } | 30 | } | ||
| 31 | + | if s.badge.w > 0 && s.badge.h > 0 { | |||
| 32 | + | class := "tag" | |||
| 33 | + | if m.mode == ports.Record { | |||
| 34 | + | class = "rec" | |||
| 35 | + | } | |||
| 36 | + | st.Box(nefergui.Key("badge"), nefergui.Class("tag"), nefergui.Class(class)). | |||
| 37 | + | Rect(s.badge.x, s.badge.y, s.badge.w, s.badge.h).Text(badgeText(m.mode)) | |||
| 38 | + | } | |||
| 27 | if n := tag(st, "header", s.header); n.ok { | 39 | if n := tag(st, "header", s.header); n.ok { | ||
| 28 | n.node.Text(m.header) | 40 | n.node.Text(m.header) | ||
| 29 | } | 41 | } | ||