detached 2c1e060

fix(ui): scale capture hints and controls with readable typography

commit
2c1e060b738a503c6fa41d8fc027bcd5209fcd80
author
brice · · 2026-09-30 13:40 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
f7ae9d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +216 -42
M README.md +2 -0
@@ -21,6 +21,8 @@ make bin
2121 - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides.
2222 - **1–9:** select that monitor. **Esc:** cancel without creating a capture.
2323
24+The selector shows mode keys and the current confirmation action in a two-line legend. Workspace hints appear only when a current workspace is available. Text uses a 1rem base (16 logical pixels), with proportional spacing and controls; the output's fractional scaling determines physical size.
25+
2426 The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`):
2527
2628 ```ini
@@ -21,6 +21,8 @@ make bin
21 - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides.21 - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides.
22 - **1–9:** select that monitor. **Esc:** cancel without creating a capture.22 - **1–9:** select that monitor. **Esc:** cancel without creating a capture.
23 23
24+The selector shows mode keys and the current confirmation action in a two-line legend. Workspace hints appear only when a current workspace is available. Text uses a 1rem base (16 logical pixels), with proportional spacing and controls; the output's fractional scaling determines physical size.
25+
24 The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`):26 The guides are visible by default: three vertical and three horizontal medium-gray lines, one physical pixel wide, aligned to each monitor's scaling. They stay fixed while dragging. To hide them on startup, set this in `$XDG_CONFIG_HOME/nefercap/config` (default `~/.config/nefercap/config`):
25 27
26 ```ini28 ```ini
M internal/adapters/indicator/indicator.go +4 -3
@@ -34,9 +34,10 @@ var styleSheet string
3434
3535 // HUD geometry in logical pixels. The Stop button is the only input region.
3636 const (
37- Width, Height = 300, 32
38- stopWidth = 72
39- topMargin = 8
37+ baseFont = 16
38+ Width, Height = baseFont * 24, baseFont * 5 / 2
39+ stopWidth = baseFont * 45 / 8 // 5.625rem in CSS
40+ topMargin = baseFont / 2
4041 namespace = "nefercap-indicator"
4142 tick = time.Second
4243 )
@@ -34,9 +34,10 @@ var styleSheet string
34 34
35 // HUD geometry in logical pixels. The Stop button is the only input region.35 // HUD geometry in logical pixels. The Stop button is the only input region.
36 const (36 const (
37- Width, Height = 300, 3237+ baseFont = 16
38- stopWidth = 7238+ Width, Height = baseFont * 24, baseFont * 5 / 2
39- topMargin = 839+ stopWidth = baseFont * 45 / 8 // 5.625rem in CSS
40+ topMargin = baseFont / 2
40 namespace = "nefercap-indicator"41 namespace = "nefercap-indicator"
41 tick = time.Second42 tick = time.Second
42 )43 )
M internal/adapters/indicator/indicator_test.go +21 -0
@@ -101,6 +101,27 @@ func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {
101101 assert.LessOrEqual(t, r.X+r.Width, int32(Width))
102102 }
103103
104+func TestHUDReadabilityAndStopGeometry(t *testing.T) {
105+ assert.Equal(t, 384, Width)
106+ assert.Equal(t, 40, Height)
107+ assert.Equal(t, 90, stopWidth)
108+ assert.Contains(t, styleSheet, "font-size: 1rem")
109+ assert.Contains(t, styleSheet, "width: 5.625rem")
110+ assert.Contains(t, styleSheet, "line-height: 2.5rem")
111+ assert.Contains(t, styleSheet, "padding: 0; line-height:")
112+ assert.Equal(t, nefergui.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())
113+}
114+
115+func BenchmarkRefreshUnchanged(b *testing.B) {
116+ start := time.Unix(100, 0)
117+ h := newHUD(start, make(chan struct{}, 1), "workspace")
118+ h.refresh(start)
119+ b.ReportAllocs()
120+ for b.Loop() {
121+ h.refresh(start)
122+ }
123+}
124+
104125 func TestRunRejectsBadSetup(t *testing.T) {
105126 stop := make(chan struct{}, 1)
106127 ok := func(context.Context, nefergui.WaylandSurface) error { return nil }
@@ -101,6 +101,27 @@ func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {
101 assert.LessOrEqual(t, r.X+r.Width, int32(Width))101 assert.LessOrEqual(t, r.X+r.Width, int32(Width))
102 }102 }
103 103
104+func TestHUDReadabilityAndStopGeometry(t *testing.T) {
105+ assert.Equal(t, 384, Width)
106+ assert.Equal(t, 40, Height)
107+ assert.Equal(t, 90, stopWidth)
108+ assert.Contains(t, styleSheet, "font-size: 1rem")
109+ assert.Contains(t, styleSheet, "width: 5.625rem")
110+ assert.Contains(t, styleSheet, "line-height: 2.5rem")
111+ assert.Contains(t, styleSheet, "padding: 0; line-height:")
112+ assert.Equal(t, nefergui.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())
113+}
114+
115+func BenchmarkRefreshUnchanged(b *testing.B) {
116+ start := time.Unix(100, 0)
117+ h := newHUD(start, make(chan struct{}, 1), "workspace")
118+ h.refresh(start)
119+ b.ReportAllocs()
120+ for b.Loop() {
121+ h.refresh(start)
122+ }
123+}
124+
104 func TestRunRejectsBadSetup(t *testing.T) {125 func TestRunRejectsBadSetup(t *testing.T) {
105 stop := make(chan struct{}, 1)126 stop := make(chan struct{}, 1)
106 ok := func(context.Context, nefergui.WaylandSurface) error { return nil }127 ok := func(context.Context, nefergui.WaylandSurface) error { return nil }
M internal/adapters/indicator/style.css +10 -5
@@ -1,16 +1,21 @@
1-/* Recording HUD: 300x32, monospace, one red accent. */
1+/* Recording HUD: 24rem x 2.5rem, monospace, one red accent. */
22 app {
33 width: 100%; height: 100%; background-color: transparent;
4- font-family: monospace; font-size: 13px; color: #e6edf3;
4+ font-family: monospace; font-size: 1rem; color: #e6edf3;
55 }
66 row.bar {
77 display: flex; flex-direction: row; align-items: center;
88 width: 100%; height: 100%; box-sizing: border-box;
9- background-color: rgba(13, 16, 20, 0.92); border: 1px solid #e5484d;
9+ background-color: rgba(13, 16, 20, 0.92);
10+}
11+text.timer {
12+ flex-grow: 1; min-width: 0; overflow: hidden;
13+ height: 100%; box-sizing: border-box; padding-left: 0.625em;
14+ line-height: 2.375rem; color: #e6edf3; border: 1px solid #e5484d;
1015 }
11-text.timer { flex-grow: 1; min-width: 0; overflow: hidden; padding-left: 10px; color: #e6edf3; }
1216 button.stop {
13- width: 72px; height: 100%; flex-shrink: 0; box-sizing: border-box;
17+ width: 5.625rem; height: 100%; flex-shrink: 0; box-sizing: border-box;
18+ padding: 0; line-height: 2.5rem;
1419 background-color: #e5484d; color: #ffffff; border: 0; text-align: center;
1520 }
1621 button.stop:hover { background-color: #ff6369; }
@@ -1,16 +1,21 @@
1-/* Recording HUD: 300x32, monospace, one red accent. */1+/* Recording HUD: 24rem x 2.5rem, monospace, one red accent. */
2 app {2 app {
3 width: 100%; height: 100%; background-color: transparent;3 width: 100%; height: 100%; background-color: transparent;
4- font-family: monospace; font-size: 13px; color: #e6edf3;4+ font-family: monospace; font-size: 1rem; color: #e6edf3;
5 }5 }
6 row.bar {6 row.bar {
7 display: flex; flex-direction: row; align-items: center;7 display: flex; flex-direction: row; align-items: center;
8 width: 100%; height: 100%; box-sizing: border-box;8 width: 100%; height: 100%; box-sizing: border-box;
9- background-color: rgba(13, 16, 20, 0.92); border: 1px solid #e5484d;9+ background-color: rgba(13, 16, 20, 0.92);
10+}
11+text.timer {
12+ flex-grow: 1; min-width: 0; overflow: hidden;
13+ height: 100%; box-sizing: border-box; padding-left: 0.625em;
14+ line-height: 2.375rem; color: #e6edf3; border: 1px solid #e5484d;
10 }15 }
11-text.timer { flex-grow: 1; min-width: 0; overflow: hidden; padding-left: 10px; color: #e6edf3; }
12 button.stop {16 button.stop {
13- width: 72px; height: 100%; flex-shrink: 0; box-sizing: border-box;17+ width: 5.625rem; height: 100%; flex-shrink: 0; box-sizing: border-box;
18+ padding: 0; line-height: 2.5rem;
14 background-color: #e5484d; color: #ffffff; border: 0; text-align: center;19 background-color: #e5484d; color: #ffffff; border: 0; text-align: center;
15 }20 }
16 button.stop:hover { background-color: #ff6369; }21 button.stop:hover { background-color: #ff6369; }
M internal/adapters/selection/model.go +20 -5
@@ -44,14 +44,16 @@ type model struct {
4444 end endKind
4545 sess *session // shared first-decision record; the only cross-overlay state
4646
47- workspace ports.Workspace // zero ID: none
48- header string // static per surface
49- footerText string
50- label label
47+ workspace ports.Workspace // zero ID: none
48+ header string // static per surface
49+ footerText string
50+ footerKind core.PickKind
51+ footerWorkspace bool
52+ label label
5153 }
5254
5355 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
54- return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode)}
56+ return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)}
5557 }
5658
5759 // resize is the OnResize callback. A logical size change (re)starts the
@@ -70,6 +72,7 @@ func (m *model) resize(w, h int, scale float64) {
7072 m.sized, m.w, m.h, m.scale = true, w, h, scale
7173 m.label.clear()
7274 m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)
75+ m.refreshFooter()
7376 m.layoutGrid()
7477 }
7578
@@ -233,7 +236,17 @@ func outputKey(name string, n int) (int, bool) {
233236 }
234237
235238 // settle mirrors the picker's terminal state and refreshes the label.
239+func (m *model) refreshFooter() {
240+ kind := m.picker.Kind()
241+ _, _, 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)
245+ }
246+}
247+
236248 func (m *model) settle() {
249+ m.refreshFooter()
237250 switch m.picker.Status() {
238251 case core.PickAccepted:
239252 r, _ := m.picker.Result()
@@ -249,6 +262,8 @@ func (m *model) settle() {
249262 m.label.set(r.Width, r.Height)
250263 } else if m.picker.Kind() == core.PickMonitor {
251264 m.label.set(m.w, m.h)
265+ } else if _, r, ok := m.picker.Workspace(); ok && m.picker.Kind() == core.PickWorkspace {
266+ m.label.set(r.Width, r.Height)
252267 } else {
253268 m.label.clear()
254269 }
@@ -44,14 +44,16 @@ type model struct {
44 end endKind44 end endKind
45 sess *session // shared first-decision record; the only cross-overlay state45 sess *session // shared first-decision record; the only cross-overlay state
46 46
47- workspace ports.Workspace // zero ID: none47+ workspace ports.Workspace // zero ID: none
48- header string // static per surface48+ header string // static per surface
49- footerText string49+ footerText string
50- label label50+ footerKind core.PickKind
51+ footerWorkspace bool
52+ label label
51 }53 }
52 54
53 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {55 func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
54- return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode)}56+ return &model{mode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false)}
55 }57 }
56 58
57 // resize is the OnResize callback. A logical size change (re)starts the59 // resize is the OnResize callback. A logical size change (re)starts the
@@ -70,6 +72,7 @@ func (m *model) resize(w, h int, scale float64) {
70 m.sized, m.w, m.h, m.scale = true, w, h, scale72 m.sized, m.w, m.h, m.scale = true, w, h, scale
71 m.label.clear()73 m.label.clear()
72 m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)74 m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)
75+ m.refreshFooter()
73 m.layoutGrid()76 m.layoutGrid()
74 }77 }
75 78
@@ -233,7 +236,17 @@ func outputKey(name string, n int) (int, bool) {
233 }236 }
234 237
235 // settle mirrors the picker's terminal state and refreshes the label.238 // settle mirrors the picker's terminal state and refreshes the label.
239+func (m *model) refreshFooter() {
240+ kind := m.picker.Kind()
241+ _, _, 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)
245+ }
246+}
247+
236 func (m *model) settle() {248 func (m *model) settle() {
249+ m.refreshFooter()
237 switch m.picker.Status() {250 switch m.picker.Status() {
238 case core.PickAccepted:251 case core.PickAccepted:
239 r, _ := m.picker.Result()252 r, _ := m.picker.Result()
@@ -249,6 +262,8 @@ func (m *model) settle() {
249 m.label.set(r.Width, r.Height)262 m.label.set(r.Width, r.Height)
250 } else if m.picker.Kind() == core.PickMonitor {263 } else if m.picker.Kind() == core.PickMonitor {
251 m.label.set(m.w, m.h)264 m.label.set(m.w, m.h)
265+ } else if _, r, ok := m.picker.Workspace(); ok && m.picker.Kind() == core.PickWorkspace {
266+ m.label.set(r.Width, r.Height)
252 } else {267 } else {
253 m.label.clear()268 m.label.clear()
254 }269 }
A internal/adapters/selection/readability_test.go +90 -0
@@ -0,0 +1,90 @@
1+package selection
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/nefercap/internal/core"
8+ "github.com/bnema/nefercap/internal/ports"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestContextualFooter(t *testing.T) {
13+ m := newTestModel(t)
14+ assert.Contains(t, m.footerText, "R region · M monitor")
15+ assert.Contains(t, m.footerText, "G grid · Esc cancel")
16+ assert.NotContains(t, m.footerText, "W workspace")
17+ assert.Contains(t, m.footerText, "drag + release capture region")
18+ m.input(key("m"))
19+ assert.Contains(t, m.footerText, "Enter capture monitor")
20+ m.input(key("r"))
21+ assert.Contains(t, m.footerText, "drag + release capture region")
22+
23+ m, _ = newBareModel()
24+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Width: 640, Height: 480}}})
25+ m.resize(640, 480, 1)
26+ assert.Contains(t, m.footerText, "W workspace")
27+ m.input(key("w"))
28+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
29+ assert.Contains(t, m.footerText, "Enter capture workspace")
30+ m.resize(600, 400, 1)
31+ assert.Contains(t, m.footerText, "drag + release capture region", "resize resets the mode")
32+}
33+
34+func TestHintsBoundsAndMetrics(t *testing.T) {
35+ for _, size := range [][2]int{{1920, 1080}, {640, 480}, {480, 320}, {200, 100}, {20, 20}} {
36+ m := newTestModel(t)
37+ m.resize(size[0], size[1], 1)
38+ for _, r := range []frect{m.scene().header, m.scene().footer} {
39+ assert.GreaterOrEqual(t, r.x, 0.0)
40+ assert.GreaterOrEqual(t, r.y, 0.0)
41+ assert.LessOrEqual(t, r.x+r.w, float64(size[0]))
42+ assert.LessOrEqual(t, r.y+r.h, float64(size[1]))
43+ }
44+ }
45+ assert.Contains(t, styleSheet, "font-size: 1rem")
46+ assert.Contains(t, styleSheet, "justify-content: center")
47+ assert.Contains(t, styleSheet, "align-items: center")
48+ assert.Equal(t, 2, len(strings.Split(newTestModel(t).footerText, "\n")))
49+ assert.GreaterOrEqual(t, footerH-2*tagPadY, 2*baseFont*1.37)
50+}
51+
52+func TestNarrowWorkspaceFooterHoldsFourLines(t *testing.T) {
53+ m, _ := newBareModel()
54+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 60, Width: 480, Height: 260}}})
55+ m.resize(480, 320, 1)
56+ m.input(key("w"))
57+ s := m.scene()
58+ // Each explicit hint row wraps once at 480px: four 24px lines plus
59+ // two 4px vertical padding edges. Do not derive this from the helper.
60+ assert.GreaterOrEqual(t, s.footer.h, 104.0)
61+ assert.LessOrEqual(t, s.footer.y+s.footer.h, 320.0)
62+ if !raceEnabled {
63+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.scene() }))
64+ }
65+}
66+
67+func TestWorkspaceLabelAvoidsOutputHeader(t *testing.T) {
68+ m, _ := newBareModel()
69+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 60, Width: 640, Height: 360}}})
70+ m.resize(640, 480, 1)
71+ m.input(key("w"))
72+ s := m.scene()
73+ assert.Equal(t, "640 × 360", m.label.text)
74+ assert.GreaterOrEqual(t, s.label.y, s.header.y+s.header.h)
75+ assert.GreaterOrEqual(t, s.label.y, 60.0, "label falls inside the workspace")
76+ assert.LessOrEqual(t, s.label.y+s.label.h, 480.0)
77+}
78+
79+func TestFooterUnchangedAllocations(t *testing.T) {
80+ if raceEnabled {
81+ t.Skip("the race detector adds allocations")
82+ }
83+ m := newTestModel(t)
84+ m.input(key("m"))
85+ assert.Zero(t, testing.AllocsPerRun(100, func() {
86+ m.input(key("m"))
87+ m.input(motion(10, 10))
88+ _ = m.scene()
89+ }))
90+}
@@ -0,0 +1,90 @@
1+package selection
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/nefercap/internal/core"
8+ "github.com/bnema/nefercap/internal/ports"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestContextualFooter(t *testing.T) {
13+ m := newTestModel(t)
14+ assert.Contains(t, m.footerText, "R region · M monitor")
15+ assert.Contains(t, m.footerText, "G grid · Esc cancel")
16+ assert.NotContains(t, m.footerText, "W workspace")
17+ assert.Contains(t, m.footerText, "drag + release capture region")
18+ m.input(key("m"))
19+ assert.Contains(t, m.footerText, "Enter capture monitor")
20+ m.input(key("r"))
21+ assert.Contains(t, m.footerText, "drag + release capture region")
22+
23+ m, _ = newBareModel()
24+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Width: 640, Height: 480}}})
25+ m.resize(640, 480, 1)
26+ assert.Contains(t, m.footerText, "W workspace")
27+ m.input(key("w"))
28+ assert.Equal(t, core.PickWorkspace, m.picker.Kind())
29+ assert.Contains(t, m.footerText, "Enter capture workspace")
30+ m.resize(600, 400, 1)
31+ assert.Contains(t, m.footerText, "drag + release capture region", "resize resets the mode")
32+}
33+
34+func TestHintsBoundsAndMetrics(t *testing.T) {
35+ for _, size := range [][2]int{{1920, 1080}, {640, 480}, {480, 320}, {200, 100}, {20, 20}} {
36+ m := newTestModel(t)
37+ m.resize(size[0], size[1], 1)
38+ for _, r := range []frect{m.scene().header, m.scene().footer} {
39+ assert.GreaterOrEqual(t, r.x, 0.0)
40+ assert.GreaterOrEqual(t, r.y, 0.0)
41+ assert.LessOrEqual(t, r.x+r.w, float64(size[0]))
42+ assert.LessOrEqual(t, r.y+r.h, float64(size[1]))
43+ }
44+ }
45+ assert.Contains(t, styleSheet, "font-size: 1rem")
46+ assert.Contains(t, styleSheet, "justify-content: center")
47+ assert.Contains(t, styleSheet, "align-items: center")
48+ assert.Equal(t, 2, len(strings.Split(newTestModel(t).footerText, "\n")))
49+ assert.GreaterOrEqual(t, footerH-2*tagPadY, 2*baseFont*1.37)
50+}
51+
52+func TestNarrowWorkspaceFooterHoldsFourLines(t *testing.T) {
53+ m, _ := newBareModel()
54+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 60, Width: 480, Height: 260}}})
55+ m.resize(480, 320, 1)
56+ m.input(key("w"))
57+ s := m.scene()
58+ // Each explicit hint row wraps once at 480px: four 24px lines plus
59+ // two 4px vertical padding edges. Do not derive this from the helper.
60+ assert.GreaterOrEqual(t, s.footer.h, 104.0)
61+ assert.LessOrEqual(t, s.footer.y+s.footer.h, 320.0)
62+ if !raceEnabled {
63+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.scene() }))
64+ }
65+}
66+
67+func TestWorkspaceLabelAvoidsOutputHeader(t *testing.T) {
68+ m, _ := newBareModel()
69+ m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 60, Width: 640, Height: 360}}})
70+ m.resize(640, 480, 1)
71+ m.input(key("w"))
72+ s := m.scene()
73+ assert.Equal(t, "640 × 360", m.label.text)
74+ assert.GreaterOrEqual(t, s.label.y, s.header.y+s.header.h)
75+ assert.GreaterOrEqual(t, s.label.y, 60.0, "label falls inside the workspace")
76+ assert.LessOrEqual(t, s.label.y+s.label.h, 480.0)
77+}
78+
79+func TestFooterUnchangedAllocations(t *testing.T) {
80+ if raceEnabled {
81+ t.Skip("the race detector adds allocations")
82+ }
83+ m := newTestModel(t)
84+ m.input(key("m"))
85+ assert.Zero(t, testing.AllocsPerRun(100, func() {
86+ m.input(key("m"))
87+ m.input(motion(10, 10))
88+ _ = m.scene()
89+ }))
90+}
M internal/adapters/selection/scene.go +59 -20
@@ -5,27 +5,38 @@ import (
55 "github.com/bnema/nefercap/internal/ports"
66 )
77
8-// Fixed overlay element sizes in logical pixels.
8+// Node.Rect takes logical pixels, not CSS units. Keep geometry in em
9+// multiples of the stylesheet's 1rem base (NeferGUI defaults to 16px).
910 const (
10- // 4px tag padding leaves 18px for the default 13px monospace line.
11- labelW, labelH = 120.0, 26.0
12- footerW, footerH = 400.0, 28.0
13- headerH = 28.0
11+ baseFont = 16.0
12+ tagPadY = baseFont * 0.25
13+ tagPadX = baseFont * 0.625
14+ labelW, labelH = baseFont * 9, baseFont * 2
15+ footerW, footerH = baseFont * 40, baseFont * 3.5
16+ headerH = labelH
1417 edge = 1.0 // thin selection lines
15- margin = 16.0
18+ margin = baseFont
1619 )
1720
18-// Footer hints; the first word names the mode.
19-const (
20- footerShot = "shot · drag region · click monitor · Esc"
21- footerRec = "rec · drag region · click monitor · Esc"
22-)
23-
24-func footerText(mode ports.Mode) string {
21+// footerText is rebuilt only when the capture mode, picker kind or workspace
22+// 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"
2525 if mode == ports.Record {
26- return footerRec
26+ text, action = "rec · R region · M monitor", "record"
27+ }
28+ if workspace {
29+ text += " · W workspace"
30+ }
31+ text += " · G grid · Esc cancel\n"
32+ 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"
37+ default:
38+ return text + "drag + release " + action + " region · click " + action + " monitor"
2739 }
28- return footerShot
2940 }
3041
3142 // frect is a float rectangle in surface-local logical pixels, the unit
@@ -92,17 +103,45 @@ func (m *model) scene() scene {
92103 if m.label.text != "" && outline {
93104 s.label = labelRect(fr, w, h)
94105 }
95- s.header = frect{margin, margin, headerWidth(m.header), headerH}
96- s.footer = frect{(w - footerW) / 2, h - footerH - margin, footerW, footerH}
97- if w < footerW+2*margin { // narrow surface: keep the hints on screen
98- s.footer.x, s.footer.w = 0, w
106+ hw, hh := min(headerWidth(m.header), w), min(headerH, h)
107+ s.header = frect{min(margin, max(w-hw, 0)), min(margin, max(h-hh, 0)), hw, hh}
108+ // Keep workspace dimensions clear of the output header; region and
109+ // monitor labels retain their independent placement.
110+ if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
111+ s.label.x < s.header.x+s.header.w && s.label.x+s.label.w > s.header.x &&
112+ s.label.y < s.header.y+s.header.h && s.label.y+s.label.h > s.header.y {
113+ y := max(fr.y+tagPadY, s.header.y+s.header.h+tagPadY)
114+ if y+s.label.h <= h {
115+ s.label.y = y
116+ }
99117 }
118+ fw := min(footerW, w)
119+ fh := min(footerHeight(m.footerText, fw), h)
120+ s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
100121 return s
101122 }
102123
124+// footerHeight counts every explicit row separately, reserving conservative
125+// monospace cells and metric lines for wrapping. Range counts runes (including
126+// the middle-dot separators) without allocating per scene.
127+func footerHeight(text string, width float64) float64 {
128+ cells := max(int((width-2*tagPadX)/(baseFont*0.625)), 1)
129+ lines, count := 0, 0
130+ for _, r := range text {
131+ if r == '\n' {
132+ lines += max((count+cells-1)/cells, 1)
133+ count = 0
134+ } else {
135+ count++
136+ }
137+ }
138+ lines += max((count+cells-1)/cells, 1)
139+ return max(footerH, float64(lines)*baseFont*1.5+2*tagPadY)
140+}
141+
103142 // headerWidth is a monospace estimate of the header text plus padding.
104143 func headerWidth(text string) float64 {
105- return float64(len([]rune(text)))*8 + 20
144+ return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX
106145 }
107146
108147 // labelRect places the size label above the region, or just inside it when
@@ -5,27 +5,38 @@ import (
5 "github.com/bnema/nefercap/internal/ports"5 "github.com/bnema/nefercap/internal/ports"
6 )6 )
7 7
8-// Fixed overlay element sizes in logical pixels.8+// Node.Rect takes logical pixels, not CSS units. Keep geometry in em
9+// multiples of the stylesheet's 1rem base (NeferGUI defaults to 16px).
9 const (10 const (
10- // 4px tag padding leaves 18px for the default 13px monospace line.11+ baseFont = 16.0
11- labelW, labelH = 120.0, 26.012+ tagPadY = baseFont * 0.25
12- footerW, footerH = 400.0, 28.013+ tagPadX = baseFont * 0.625
13- headerH = 28.014+ labelW, labelH = baseFont * 9, baseFont * 2
15+ footerW, footerH = baseFont * 40, baseFont * 3.5
16+ headerH = labelH
14 edge = 1.0 // thin selection lines17 edge = 1.0 // thin selection lines
15- margin = 16.018+ margin = baseFont
16 )19 )
17 20
18-// Footer hints; the first word names the mode.21+// footerText is rebuilt only when the capture mode, picker kind or workspace
19-const (22+// availability changes; pointer motion never formats hints.
20- footerShot = "shot · drag region · click monitor · Esc"23+func footerText(mode ports.Mode, kind core.PickKind, workspace bool) string {
21- footerRec = "rec · drag region · click monitor · Esc"24+ text, action := "shot · R region · M monitor", "capture"
22-)
23-
24-func footerText(mode ports.Mode) string {
25 if mode == ports.Record {25 if mode == ports.Record {
26- return footerRec26+ text, action = "rec · R region · M monitor", "record"
27+ }
28+ if workspace {
29+ text += " · W workspace"
30+ }
31+ text += " · G grid · Esc cancel\n"
32+ 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"
37+ default:
38+ return text + "drag + release " + action + " region · click " + action + " monitor"
27 }39 }
28- return footerShot
29 }40 }
30 41
31 // frect is a float rectangle in surface-local logical pixels, the unit42 // frect is a float rectangle in surface-local logical pixels, the unit
@@ -92,17 +103,45 @@ func (m *model) scene() scene {
92 if m.label.text != "" && outline {103 if m.label.text != "" && outline {
93 s.label = labelRect(fr, w, h)104 s.label = labelRect(fr, w, h)
94 }105 }
95- s.header = frect{margin, margin, headerWidth(m.header), headerH}106+ hw, hh := min(headerWidth(m.header), w), min(headerH, h)
96- s.footer = frect{(w - footerW) / 2, h - footerH - margin, footerW, footerH}107+ s.header = frect{min(margin, max(w-hw, 0)), min(margin, max(h-hh, 0)), hw, hh}
97- if w < footerW+2*margin { // narrow surface: keep the hints on screen108+ // Keep workspace dimensions clear of the output header; region and
98- s.footer.x, s.footer.w = 0, w109+ // monitor labels retain their independent placement.
110+ if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
111+ s.label.x < s.header.x+s.header.w && s.label.x+s.label.w > s.header.x &&
112+ s.label.y < s.header.y+s.header.h && s.label.y+s.label.h > s.header.y {
113+ y := max(fr.y+tagPadY, s.header.y+s.header.h+tagPadY)
114+ if y+s.label.h <= h {
115+ s.label.y = y
116+ }
99 }117 }
118+ fw := min(footerW, w)
119+ fh := min(footerHeight(m.footerText, fw), h)
120+ s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
100 return s121 return s
101 }122 }
102 123
124+// footerHeight counts every explicit row separately, reserving conservative
125+// monospace cells and metric lines for wrapping. Range counts runes (including
126+// the middle-dot separators) without allocating per scene.
127+func footerHeight(text string, width float64) float64 {
128+ cells := max(int((width-2*tagPadX)/(baseFont*0.625)), 1)
129+ lines, count := 0, 0
130+ for _, r := range text {
131+ if r == '\n' {
132+ lines += max((count+cells-1)/cells, 1)
133+ count = 0
134+ } else {
135+ count++
136+ }
137+ }
138+ lines += max((count+cells-1)/cells, 1)
139+ return max(footerH, float64(lines)*baseFont*1.5+2*tagPadY)
140+}
141+
103 // headerWidth is a monospace estimate of the header text plus padding.142 // headerWidth is a monospace estimate of the header text plus padding.
104 func headerWidth(text string) float64 {143 func headerWidth(text string) float64 {
105- return float64(len([]rune(text)))*8 + 20144+ return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX
106 }145 }
107 146
108 // labelRect places the size label above the region, or just inside it when147 // labelRect places the size label above the region, or just inside it when
M internal/adapters/selection/selection_test.go +6 -6
@@ -593,7 +593,7 @@ func BenchmarkGridResizeScene(b *testing.B) {
593593
594594 func TestLabelPlacementStaysOnSurface(t *testing.T) {
595595 // Above the region, with a 4px gap.
596- assert.Equal(t, frect{1800, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
596+ assert.Equal(t, frect{1920 - labelW, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
597597 // No room above: just inside the region's top-left corner.
598598 assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))
599599 // Exactly enough room above: the label touches the gap, not the edge.
@@ -621,19 +621,19 @@ func TestLabelStaysOnSurfaceWhenItFits(t *testing.T) {
621621 }
622622 }
623623
624-// The tallest default 13px monospace line is 17.71px (Noto Sans Mono).
624+// Noto Sans Mono's normal line is approximately 1.37em tall.
625625 // Keep the sizing assumptions explicit rather than partially parsing CSS.
626626 func TestLabelContentHoldsMonospaceLine(t *testing.T) {
627- const line, padY = 17.71, 4.0
628- require.True(t, strings.Contains(styleSheet, "box-sizing: border-box; padding: 4px 10px; overflow: hidden;"), "update label sizing when tag box sizing changes")
627+ const line, padY = baseFont * 1.37, tagPadY
628+ require.True(t, strings.Contains(styleSheet, "box-sizing: border-box; padding: 0.25em 0.625em; overflow: hidden;"), "update label sizing when tag box sizing changes")
629629 content := labelH - 2*padY
630630 assert.GreaterOrEqual(t, content, line, "label clips its text line")
631631 assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line")
632632 }
633633
634634 func TestFooterAndHeaderText(t *testing.T) {
635- assert.Equal(t, "shot · drag region · click monitor · Esc", footerText(ports.Screenshot))
636- assert.Equal(t, "rec · drag region · click monitor · Esc", footerText(ports.Record))
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")
637637 assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
638638 assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
639639 i, ok := outputKey("2", 2)
@@ -593,7 +593,7 @@ func BenchmarkGridResizeScene(b *testing.B) {
593 593
594 func TestLabelPlacementStaysOnSurface(t *testing.T) {594 func TestLabelPlacementStaysOnSurface(t *testing.T) {
595 // Above the region, with a 4px gap.595 // Above the region, with a 4px gap.
596- assert.Equal(t, frect{1800, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))596+ assert.Equal(t, frect{1920 - labelW, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
597 // No room above: just inside the region's top-left corner.597 // No room above: just inside the region's top-left corner.
598 assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))598 assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))
599 // Exactly enough room above: the label touches the gap, not the edge.599 // Exactly enough room above: the label touches the gap, not the edge.
@@ -621,19 +621,19 @@ func TestLabelStaysOnSurfaceWhenItFits(t *testing.T) {
621 }621 }
622 }622 }
623 623
624-// The tallest default 13px monospace line is 17.71px (Noto Sans Mono).624+// Noto Sans Mono's normal line is approximately 1.37em tall.
625 // Keep the sizing assumptions explicit rather than partially parsing CSS.625 // Keep the sizing assumptions explicit rather than partially parsing CSS.
626 func TestLabelContentHoldsMonospaceLine(t *testing.T) {626 func TestLabelContentHoldsMonospaceLine(t *testing.T) {
627- const line, padY = 17.71, 4.0627+ const line, padY = baseFont * 1.37, tagPadY
628- require.True(t, strings.Contains(styleSheet, "box-sizing: border-box; padding: 4px 10px; overflow: hidden;"), "update label sizing when tag box sizing changes")628+ require.True(t, strings.Contains(styleSheet, "box-sizing: border-box; padding: 0.25em 0.625em; overflow: hidden;"), "update label sizing when tag box sizing changes")
629 content := labelH - 2*padY629 content := labelH - 2*padY
630 assert.GreaterOrEqual(t, content, line, "label clips its text line")630 assert.GreaterOrEqual(t, content, line, "label clips its text line")
631 assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line")631 assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line")
632 }632 }
633 633
634 func TestFooterAndHeaderText(t *testing.T) {634 func TestFooterAndHeaderText(t *testing.T) {
635- assert.Equal(t, "shot · drag region · click monitor · Esc", footerText(ports.Screenshot))635+ assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false), "shot · R region")
636- assert.Equal(t, "rec · drag region · click monitor · Esc", footerText(ports.Record))636+ assert.Contains(t, footerText(ports.Record, core.PickRegion, 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/style.css +4 -3
@@ -1,14 +1,15 @@
11 /* Transparent selector overlay: dimming, thin gray guides, monospace tags. */
22 app {
33 width: 100%; height: 100%; background-color: transparent;
4- color: #e6edf3; font-family: monospace; font-size: 13px;
4+ color: #e6edf3; font-family: monospace; font-size: 1rem;
55 }
66 stack.scene { width: 100%; height: 100%; }
77 box.dim { background-color: rgba(0, 0, 0, 0.5); }
88 box.edge { background-color: #4fd1a5; }
99 box.grid { background-color: #808080; }
1010 box.tag {
11- box-sizing: border-box; padding: 4px 10px; overflow: hidden;
11+ display: flex; flex-direction: row; justify-content: center; align-items: center;
12+ box-sizing: border-box; padding: 0.25em 0.625em; overflow: hidden;
1213 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
13- font-family: monospace; font-size: 13px; text-align: center;
14+ font-family: monospace; font-size: 1rem; text-align: center;
1415 }
@@ -1,14 +1,15 @@
1 /* Transparent selector overlay: dimming, thin gray guides, monospace tags. */1 /* Transparent selector overlay: dimming, thin gray guides, monospace tags. */
2 app {2 app {
3 width: 100%; height: 100%; background-color: transparent;3 width: 100%; height: 100%; background-color: transparent;
4- color: #e6edf3; font-family: monospace; font-size: 13px;4+ color: #e6edf3; font-family: monospace; font-size: 1rem;
5 }5 }
6 stack.scene { width: 100%; height: 100%; }6 stack.scene { width: 100%; height: 100%; }
7 box.dim { background-color: rgba(0, 0, 0, 0.5); }7 box.dim { background-color: rgba(0, 0, 0, 0.5); }
8 box.edge { background-color: #4fd1a5; }8 box.edge { background-color: #4fd1a5; }
9 box.grid { background-color: #808080; }9 box.grid { background-color: #808080; }
10 box.tag {10 box.tag {
11- box-sizing: border-box; padding: 4px 10px; overflow: hidden;11+ display: flex; flex-direction: row; justify-content: center; align-items: center;
12+ box-sizing: border-box; padding: 0.25em 0.625em; overflow: hidden;
12 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;13 background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
13- font-family: monospace; font-size: 13px; text-align: center;14+ font-family: monospace; font-size: 1rem; text-align: center;
14 }15 }

Keyboard shortcuts

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

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

Branches 2

Tags 9

↑↓ moveenter openesc close