detached ff29f3d

test: tighten SameAs, headless and config tests; fix animation doc claims

commit
ff29f3d073ea648c2a4013c609eeb409c4c93682
author
brice · · 2026-10-04 18:44 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
ad7909a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +41 -30
M docs/config.md +1 -1
@@ -45,7 +45,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4545 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
4646 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
4747 | `animations` | `on` | `off` makes every change instant: swipe landings, scrolling, workspace switches, window moves and resizes, the focus pulse |
48-| `animations.slowdown` | `1` | Multiplies the duration of every transition, `0.1` to `10` |
48+| `animations.slowdown` | `1` | Multiplies the duration of every spring (not the focus pulse), `0.1` to `10` |
4949 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
5050 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
5151 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
@@ -45,7 +45,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47 | `animations` | `on` | `off` makes every change instant: swipe landings, scrolling, workspace switches, window moves and resizes, the focus pulse |47 | `animations` | `on` | `off` makes every change instant: swipe landings, scrolling, workspace switches, window moves and resizes, the focus pulse |
48-| `animations.slowdown` | `1` | Multiplies the duration of every transition, `0.1` to `10` |48+| `animations.slowdown` | `1` | Multiplies the duration of every spring (not the focus pulse), `0.1` to `10` |
49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |50 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
M docs/performance.md +1 -1
@@ -8,7 +8,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
88 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
1010 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11-- **Transitions.** Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen and the floating toggle each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Window map and unmap, client-driven resizes, panels, hotplug and the overview never animate. A frame costs 0 allocations, guarded by `make perf-check`. An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. Clients get one configure, to the final size; the window frame moves and resizes while the content is not scaled, only clipped or padded. Measured cost per composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.6 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz.
11+- **Transitions.** Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen and the floating toggle each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Window map and unmap, client-driven resizes, panels, hotplug and the overview never animate. Advancing the springs costs 0 allocations, guarded by `make perf-check`. An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. Clients get one configure, to the final size; the window frame moves and resizes while the content is not scaled, only clipped or padded. Measured cost per composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.6 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz.
1212 - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition, and so does the capture indicator (red border or pill, shown while a session lives and for one second after each captured frame; see `docs/headless.md`), which also costs a second composition per capture frame because no capture holds it (a request waits at most 250 ms for the scene that shows its indicator, without drawing anything while it waits); hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
1313 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1414
@@ -8,7 +8,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
8 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.8 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.9 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
10 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.10 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11-- **Transitions.** Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen and the floating toggle each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Window map and unmap, client-driven resizes, panels, hotplug and the overview never animate. A frame costs 0 allocations, guarded by `make perf-check`. An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. Clients get one configure, to the final size; the window frame moves and resizes while the content is not scaled, only clipped or padded. Measured cost per composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.6 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz.11+- **Transitions.** Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen and the floating toggle each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Window map and unmap, client-driven resizes, panels, hotplug and the overview never animate. Advancing the springs costs 0 allocations, guarded by `make perf-check`. An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. Clients get one configure, to the final size; the window frame moves and resizes while the content is not scaled, only clipped or padded. Measured cost per composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.6 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz.
12 - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition, and so does the capture indicator (red border or pill, shown while a session lives and for one second after each captured frame; see `docs/headless.md`), which also costs a second composition per capture frame because no capture holds it (a request waits at most 250 ms for the scene that shows its indicator, without drawing anything while it waits); hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.12 - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition, and so does the capture indicator (red border or pill, shown while a session lives and for one second after each captured frame; see `docs/headless.md`), which also costs a second composition per capture frame because no capture holds it (a request waits at most 250 ms for the scene that shows its indicator, without drawing anything while it waits); hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
13 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.13 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
14 14
M examples/config +1 -1
@@ -73,7 +73,7 @@ cursor.hide-after = 5s
7373 # on: moving a column or window to another workspace shows that workspace.
7474 focus.follow-move = off
7575 # on: windows and the view move with springs; off: every change is instant.
76-# slowdown multiplies every transition's duration (0.1 to 10).
76+# slowdown multiplies every spring's duration (0.1 to 10).
7777 animations = on
7878 animations.slowdown = 1
7979 # Focus indicator on the newly focused window: an animation (pulse or off)
@@ -73,7 +73,7 @@ cursor.hide-after = 5s
73 # on: moving a column or window to another workspace shows that workspace.73 # on: moving a column or window to another workspace shows that workspace.
74 focus.follow-move = off74 focus.follow-move = off
75 # on: windows and the view move with springs; off: every change is instant.75 # on: windows and the view move with springs; off: every change is instant.
76-# slowdown multiplies every transition's duration (0.1 to 10).76+# slowdown multiplies every spring's duration (0.1 to 10).
77 animations = on77 animations = on
78 animations.slowdown = 178 animations.slowdown = 1
79 # Focus indicator on the newly focused window: an animation (pulse or off)79 # Focus indicator on the newly focused window: an animation (pulse or off)
M internal/adapters/config/config_test.go +3 -4
@@ -92,10 +92,9 @@ func TestAnimations(t *testing.T) {
9292 if c.Animations != d || len(w) != 1 {
9393 t.Fatal(line, c.Animations, w)
9494 }
95- }
96- _, w = parseString(t, "animations.slowdown = 11\n")
97- if len(w) != 1 || !strings.Contains(w[0].Msg, "must be between 0.1 and 10") {
98- t.Fatal(w)
95+ if strings.Contains(line, "slowdown") && !strings.Contains(w[0].Msg, "must be between 0.1 and 10") {
96+ t.Fatal(line, w)
97+ }
9998 }
10099 }
101100
@@ -92,10 +92,9 @@ func TestAnimations(t *testing.T) {
92 if c.Animations != d || len(w) != 1 {92 if c.Animations != d || len(w) != 1 {
93 t.Fatal(line, c.Animations, w)93 t.Fatal(line, c.Animations, w)
94 }94 }
95- }95+ if strings.Contains(line, "slowdown") && !strings.Contains(w[0].Msg, "must be between 0.1 and 10") {
96- _, w = parseString(t, "animations.slowdown = 11\n")96+ t.Fatal(line, w)
97- if len(w) != 1 || !strings.Contains(w[0].Msg, "must be between 0.1 and 10") {97+ }
98- t.Fatal(w)
99 }98 }
100 }99 }
101 100
M internal/adapters/headless/output_test.go +0 -1
@@ -125,7 +125,6 @@ func TestHeadlessSameSeqRendersOnce(t *testing.T) {
125125 // Unshown content sends Run round its loop: the held scene is handled.
126126 contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
127127 contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
128- time.Sleep(50 * time.Millisecond)
129128 if s, _ := f.snapshot(); len(s) != 1 {
130129 t.Fatalf("%d frames for a scene with the held Seq", len(s))
131130 }
@@ -125,7 +125,6 @@ func TestHeadlessSameSeqRendersOnce(t *testing.T) {
125 // Unshown content sends Run round its loop: the held scene is handled.125 // Unshown content sends Run round its loop: the held scene is handled.
126 contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}126 contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
127 contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}127 contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
128- time.Sleep(50 * time.Millisecond)
129 if s, _ := f.snapshot(); len(s) != 1 {128 if s, _ := f.snapshot(); len(s) != 1 {
130 t.Fatalf("%d frames for a scene with the held Seq", len(s))129 t.Fatalf("%d frames for a scene with the held Seq", len(s))
131 }130 }
M internal/adapters/vulkan/scene_rect_content_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
10-// A window animated by option B shows a rect that differs from its content
10+// A window under a presentation-only rect transition shows a rect that differs from its content
1111 // (the client has its final size already). These walk tests need no Vulkan
1212 // device: a single-pixel buffer is a solid quad.
1313 func TestSceneContentVsWindowRect(t *testing.T) {
@@ -7,7 +7,7 @@ import (
7 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
8 )8 )
9 9
10-// A window animated by option B shows a rect that differs from its content10+// A window under a presentation-only rect transition shows a rect that differs from its content
11 // (the client has its final size already). These walk tests need no Vulkan11 // (the client has its final size already). These walk tests need no Vulkan
12 // device: a single-pixel buffer is a solid quad.12 // device: a single-pixel buffer is a solid quad.
13 func TestSceneContentVsWindowRect(t *testing.T) {13 func TestSceneContentVsWindowRect(t *testing.T) {
M internal/ports/config.go +1 -1
@@ -144,7 +144,7 @@ type Config struct {
144144 // motion; the next motion shows it. 0 never hides it.
145145 Cursor struct{ HideAfter time.Duration }
146146 // Animations: On enables motion (off lands everything instantly);
147- // Slowdown scales every duration, 0.1 to 10.
147+ // Slowdown scales every spring's duration (not the focus pulse), 0.1 to 10.
148148 Animations struct {
149149 On bool
150150 Slowdown float64
@@ -144,7 +144,7 @@ type Config struct {
144 // motion; the next motion shows it. 0 never hides it.144 // motion; the next motion shows it. 0 never hides it.
145 Cursor struct{ HideAfter time.Duration }145 Cursor struct{ HideAfter time.Duration }
146 // Animations: On enables motion (off lands everything instantly);146 // Animations: On enables motion (off lands everything instantly);
147- // Slowdown scales every duration, 0.1 to 10.147+ // Slowdown scales every spring's duration (not the focus pulse), 0.1 to 10.
148 Animations struct {148 Animations struct {
149 On bool149 On bool
150 Slowdown float64150 Slowdown float64
M internal/ports/scene_same_test.go +33 -20
@@ -36,26 +36,31 @@ func TestSceneSameAs(t *testing.T) {
3636 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
3737
3838 for name, edit := range map[string]func(*Scene){
39- "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40- "window count": func(s *Scene) { s.Windows = nil },
41- "security": func(s *Scene) { s.Security.Generation++ },
42- "background": func(s *Scene) { s.Background = "#111111" },
43- "border": func(s *Scene) { s.Border.Width++ },
44- "output": func(s *Scene) { s.Output = "B" },
45- "size": func(s *Scene) { s.OutputWidth++ },
46- "scale": func(s *Scene) { s.Scale = 2 },
47- "off": func(s *Scene) { s.Off = true },
48- "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
49- "dim": func(s *Scene) { s.Dim = 0.5 },
50- "dim behind": func(s *Scene) { s.DimBehind = true },
51- "separator": func(s *Scene) { s.Separators[0].Active = true },
52- "drop hint": func(s *Scene) { s.DropHints[0].W++ },
53- "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
54- "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
55- "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
56- "capture field": func(s *Scene) { s.Capture.Revision++ },
57- "capture nil": func(s *Scene) { s.Capture = nil },
58- "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
39+ "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window count": func(s *Scene) { s.Windows = nil },
41+ "security": func(s *Scene) { s.Security.Generation++ },
42+ "background": func(s *Scene) { s.Background = "#111111" },
43+ "border": func(s *Scene) { s.Border.Width++ },
44+ "output": func(s *Scene) { s.Output = "B" },
45+ "size": func(s *Scene) { s.OutputWidth++ },
46+ "scale": func(s *Scene) { s.Scale = 2 },
47+ "off": func(s *Scene) { s.Off = true },
48+ "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
49+ "dim": func(s *Scene) { s.Dim = 0.5 },
50+ "dim behind": func(s *Scene) { s.DimBehind = true },
51+ "separator": func(s *Scene) { s.Separators[0].Active = true },
52+ "drop hint": func(s *Scene) { s.DropHints[0].W++ },
53+ "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
54+ "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
55+ "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
56+ "transform": func(s *Scene) { s.Transform = 1 },
57+ "capture shown": func(s *Scene) { s.Capture.Shown++ },
58+ "capture session": func(s *Scene) { s.Capture.Session++ },
59+ "capture workspace": func(s *Scene) { s.Capture.Workspace++ },
60+ "capture window": func(s *Scene) { s.Capture.Window++ },
61+ "capture revision": func(s *Scene) { s.Capture.Revision++ },
62+ "capture nil": func(s *Scene) { s.Capture = nil },
63+ "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
5964 } {
6065 t.Run(name, func(t *testing.T) {
6166 o := sameSceneBase()
@@ -79,3 +84,11 @@ func TestSceneSameAsCoversEveryField(t *testing.T) {
7984 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
8085 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
8186 }
87+
88+// TestSceneCaptureSameAsCoversEveryField fails when SceneCapture gains or
89+// loses a field: update Scene.SameAs, then this count.
90+func TestSceneCaptureSameAsCoversEveryField(t *testing.T) {
91+ const fields = 6
92+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(SceneCapture{}))),
93+ "SceneCapture fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
94+}
@@ -36,26 +36,31 @@ func TestSceneSameAs(t *testing.T) {
36 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))36 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
37 37
38 for name, edit := range map[string]func(*Scene){38 for name, edit := range map[string]func(*Scene){
39- "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },39+ "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40- "window count": func(s *Scene) { s.Windows = nil },40+ "window count": func(s *Scene) { s.Windows = nil },
41- "security": func(s *Scene) { s.Security.Generation++ },41+ "security": func(s *Scene) { s.Security.Generation++ },
42- "background": func(s *Scene) { s.Background = "#111111" },42+ "background": func(s *Scene) { s.Background = "#111111" },
43- "border": func(s *Scene) { s.Border.Width++ },43+ "border": func(s *Scene) { s.Border.Width++ },
44- "output": func(s *Scene) { s.Output = "B" },44+ "output": func(s *Scene) { s.Output = "B" },
45- "size": func(s *Scene) { s.OutputWidth++ },45+ "size": func(s *Scene) { s.OutputWidth++ },
46- "scale": func(s *Scene) { s.Scale = 2 },46+ "scale": func(s *Scene) { s.Scale = 2 },
47- "off": func(s *Scene) { s.Off = true },47+ "off": func(s *Scene) { s.Off = true },
48- "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },48+ "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
49- "dim": func(s *Scene) { s.Dim = 0.5 },49+ "dim": func(s *Scene) { s.Dim = 0.5 },
50- "dim behind": func(s *Scene) { s.DimBehind = true },50+ "dim behind": func(s *Scene) { s.DimBehind = true },
51- "separator": func(s *Scene) { s.Separators[0].Active = true },51+ "separator": func(s *Scene) { s.Separators[0].Active = true },
52- "drop hint": func(s *Scene) { s.DropHints[0].W++ },52+ "drop hint": func(s *Scene) { s.DropHints[0].W++ },
53- "layer": func(s *Scene) { s.Layers[0].Rect.H++ },53+ "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
54- "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },54+ "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
55- "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },55+ "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
56- "capture field": func(s *Scene) { s.Capture.Revision++ },56+ "transform": func(s *Scene) { s.Transform = 1 },
57- "capture nil": func(s *Scene) { s.Capture = nil },57+ "capture shown": func(s *Scene) { s.Capture.Shown++ },
58- "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },58+ "capture session": func(s *Scene) { s.Capture.Session++ },
59+ "capture workspace": func(s *Scene) { s.Capture.Workspace++ },
60+ "capture window": func(s *Scene) { s.Capture.Window++ },
61+ "capture revision": func(s *Scene) { s.Capture.Revision++ },
62+ "capture nil": func(s *Scene) { s.Capture = nil },
63+ "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
59 } {64 } {
60 t.Run(name, func(t *testing.T) {65 t.Run(name, func(t *testing.T) {
61 o := sameSceneBase()66 o := sameSceneBase()
@@ -79,3 +84,11 @@ func TestSceneSameAsCoversEveryField(t *testing.T) {
79 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),84 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
80 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")85 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
81 }86 }
87+
88+// TestSceneCaptureSameAsCoversEveryField fails when SceneCapture gains or
89+// loses a field: update Scene.SameAs, then this count.
90+func TestSceneCaptureSameAsCoversEveryField(t *testing.T) {
91+ const fields = 6
92+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(SceneCapture{}))),
93+ "SceneCapture fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
94+}

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 25

Tags 10

↑↓ moveenter openesc close