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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +216 -42
M README.md +2 -0
| @@ -21,6 +21,8 @@ make bin | |||
| 21 | 21 | - **R:** return to rectangle selection. **G:** toggle full-monitor center and thirds guides. | |
| 22 | 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 | 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 | 28 | ```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 | ```ini | 28 | ```ini | ||
M internal/adapters/indicator/indicator.go +4 -3
| @@ -34,9 +34,10 @@ var styleSheet string | |||
| 34 | 34 | ||
| 35 | 35 | // HUD geometry in logical pixels. The Stop button is the only input region. | |
| 36 | 36 | 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 | |
| 40 | 41 | namespace = "nefercap-indicator" | |
| 41 | 42 | tick = time.Second | |
| 42 | 43 | ) | |
| @@ -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, 32 | 37 | + | baseFont = 16 |
| 38 | - | stopWidth = 72 | 38 | + | Width, Height = baseFont * 24, baseFont * 5 / 2 |
| 39 | - | topMargin = 8 | 39 | + | stopWidth = baseFont * 45 / 8 // 5.625rem in CSS |
| 40 | + | topMargin = baseFont / 2 | |||
| 40 | namespace = "nefercap-indicator" | 41 | namespace = "nefercap-indicator" | ||
| 41 | tick = time.Second | 42 | tick = time.Second | ||
| 42 | ) | 43 | ) | ||
M internal/adapters/indicator/indicator_test.go +21 -0
| @@ -101,6 +101,27 @@ func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | |||
| 101 | 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 | 125 | func TestRunRejectsBadSetup(t *testing.T) { | |
| 105 | 126 | stop := make(chan struct{}, 1) | |
| 106 | 127 | 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. */ | |
| 2 | 2 | app { | |
| 3 | 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 | 6 | row.bar { | |
| 7 | 7 | display: flex; flex-direction: row; align-items: center; | |
| 8 | 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 | 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 | 19 | background-color: #e5484d; color: #ffffff; border: 0; text-align: center; | |
| 15 | 20 | } | |
| 16 | 21 | 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 { | |||
| 44 | 44 | end endKind | |
| 45 | 45 | sess *session // shared first-decision record; the only cross-overlay state | |
| 46 | 46 | ||
| 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 | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 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 | 59 | // 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 | 72 | m.sized, m.w, m.h, m.scale = true, w, h, scale | |
| 71 | 73 | m.label.clear() | |
| 72 | 74 | m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region) | |
| 75 | + | m.refreshFooter() | |
| 73 | 76 | m.layoutGrid() | |
| 74 | 77 | } | |
| 75 | 78 | ||
| @@ -233,7 +236,17 @@ func outputKey(name string, n int) (int, bool) { | |||
| 233 | 236 | } | |
| 234 | 237 | ||
| 235 | 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 | 248 | func (m *model) settle() { | |
| 249 | + | m.refreshFooter() | |
| 237 | 250 | switch m.picker.Status() { | |
| 238 | 251 | case core.PickAccepted: | |
| 239 | 252 | r, _ := m.picker.Result() | |
| @@ -249,6 +262,8 @@ func (m *model) settle() { | |||
| 249 | 262 | m.label.set(r.Width, r.Height) | |
| 250 | 263 | } else if m.picker.Kind() == core.PickMonitor { | |
| 251 | 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 | 267 | } else { | |
| 253 | 268 | m.label.clear() | |
| 254 | 269 | } | |
| @@ -44,14 +44,16 @@ type model struct { | |||||
| 44 | end endKind | 44 | end endKind | ||
| 45 | sess *session // shared first-decision record; the only cross-overlay state | 45 | sess *session // shared first-decision record; the only cross-overlay state | ||
| 46 | 46 | ||||
| 47 | - | workspace ports.Workspace // zero ID: none | 47 | + | workspace ports.Workspace // zero ID: none |
| 48 | - | header string // static per surface | 48 | + | header string // static per surface |
| 49 | - | footerText string | 49 | + | footerText string |
| 50 | - | label label | 50 | + | 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 the | 59 | // 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, scale | 72 | 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 ( | |||
| 5 | 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 | 10 | 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 | |
| 14 | 17 | edge = 1.0 // thin selection lines | |
| 15 | - | margin = 16.0 | |
| 18 | + | margin = baseFont | |
| 16 | 19 | ) | |
| 17 | 20 | ||
| 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" | |
| 25 | 25 | 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" | |
| 27 | 39 | } | |
| 28 | - | return footerShot | |
| 29 | 40 | } | |
| 30 | 41 | ||
| 31 | 42 | // frect is a float rectangle in surface-local logical pixels, the unit | |
| @@ -92,17 +103,45 @@ func (m *model) scene() scene { | |||
| 92 | 103 | if m.label.text != "" && outline { | |
| 93 | 104 | s.label = labelRect(fr, w, h) | |
| 94 | 105 | } | |
| 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 | + | } | |
| 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 | 121 | 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 | 142 | // headerWidth is a monospace estimate of the header text plus padding. | |
| 104 | 143 | func headerWidth(text string) float64 { | |
| 105 | - | return float64(len([]rune(text)))*8 + 20 | |
| 144 | + | return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX | |
| 106 | 145 | } | |
| 107 | 146 | ||
| 108 | 147 | // 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.0 | 12 | + | tagPadY = baseFont * 0.25 |
| 12 | - | footerW, footerH = 400.0, 28.0 | 13 | + | tagPadX = baseFont * 0.625 |
| 13 | - | headerH = 28.0 | 14 | + | labelW, labelH = baseFont * 9, baseFont * 2 |
| 15 | + | footerW, footerH = baseFont * 40, baseFont * 3.5 | |||
| 16 | + | headerH = labelH | |||
| 14 | edge = 1.0 // thin selection lines | 17 | edge = 1.0 // thin selection lines | ||
| 15 | - | margin = 16.0 | 18 | + | 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 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" | |||
| 27 | } | 39 | } | ||
| 28 | - | return footerShot | |||
| 29 | } | 40 | } | ||
| 30 | 41 | ||||
| 31 | // frect is a float rectangle in surface-local logical pixels, the unit | 42 | // 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 screen | 108 | + | // Keep workspace dimensions clear of the output header; region and |
| 98 | - | s.footer.x, s.footer.w = 0, w | 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 | + | } | |||
| 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 s | 121 | 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 + 20 | 144 | + | 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 when | 147 | // 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) { | |||
| 593 | 593 | ||
| 594 | 594 | func TestLabelPlacementStaysOnSurface(t *testing.T) { | |
| 595 | 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 | 597 | // No room above: just inside the region's top-left corner. | |
| 598 | 598 | assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080)) | |
| 599 | 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 | 625 | // Keep the sizing assumptions explicit rather than partially parsing CSS. | |
| 626 | 626 | 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") | |
| 629 | 629 | content := labelH - 2*padY | |
| 630 | 630 | assert.GreaterOrEqual(t, content, line, "label clips its text line") | |
| 631 | 631 | assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line") | |
| 632 | 632 | } | |
| 633 | 633 | ||
| 634 | 634 | 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") | |
| 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) | |
| @@ -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.0 | 627 | + | 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*padY | 629 | 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 @@ | |||
| 1 | 1 | /* Transparent selector overlay: dimming, thin gray guides, monospace tags. */ | |
| 2 | 2 | app { | |
| 3 | 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 | 6 | stack.scene { width: 100%; height: 100%; } | |
| 7 | 7 | box.dim { background-color: rgba(0, 0, 0, 0.5); } | |
| 8 | 8 | box.edge { background-color: #4fd1a5; } | |
| 9 | 9 | box.grid { background-color: #808080; } | |
| 10 | 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 | 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 | } | |
| @@ -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 | } | ||