detached f28b0f0

fix(power): keep an output that reconnects while off turned off (#107)

* fix(power): keep an output that reconnects while off turned off

* fix(drm): start an output that reconnects while off without lighting it up

* fix(drm): probe tearing once an output that started off turns on

* test(app): check the output set remembers which outputs are off
commit
f28b0f0ac3c490daadace326f2688a2a30fa9d30
author
bnema · · 2026-10-03 12:51 UTC
committer
GitHub · · 2026-10-03 12:51 UTC
parent
b9da8c3
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +208 -12
M docs/desktop.md +1 -1
@@ -77,7 +77,7 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext
7777
7878 ## Idle and screen off
7979
80-NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on.
80+NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. A display that disconnects in deep sleep comes back off when it reconnects, unless there was keyboard, pointer or touch input in the meantime.
8181
8282 Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back.
8383
@@ -77,7 +77,7 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext
77 77
78 ## Idle and screen off78 ## Idle and screen off
79 79
80-NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on.80+NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. A display that disconnects in deep sleep comes back off when it reconnects, unless there was keyboard, pointer or touch input in the meantime.
81 81
82 Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back.82 Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back.
83 83
M internal/adapters/drm/output.go +7 -0
@@ -73,6 +73,9 @@ type Output struct {
7373 // capture session (Scene.CaptureScene). Nil: such captures fail closed.
7474 // Set before Run.
7575 NewCaptureRenderer func(w, h int) (ports.Renderer, error)
76+ // StartOff, set before Run, keeps the display off until a scene turns
77+ // it on: a display that reconnects while turned off never lights up.
78+ StartOff bool
7679 // capHidden limits a report to what the child renderer of a hidden
7780 // workspace was given, window by window, while it may still read (its
7881 // device is not ordered with the display's fences). capped is set when
@@ -888,6 +891,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
888891 }
889892 }()
890893 o.runContext = ctx
894+ o.wantOff = o.StartOff
891895 o.observeSecurity()
892896 r, err := newRenderer(o.Width(), o.Height())
893897 if err != nil {
@@ -1300,6 +1304,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13001304 err = o.powerOff()
13011305 } else if err = o.modeset(); err == nil {
13021306 dirty = true
1307+ if !o.protected {
1308+ o.probeAsync(r) // skipped while it started off
1309+ }
13031310 }
13041311 if err != nil && !o.commitFailed(err, &enabled) {
13051312 return err
@@ -73,6 +73,9 @@ type Output struct {
73 // capture session (Scene.CaptureScene). Nil: such captures fail closed.73 // capture session (Scene.CaptureScene). Nil: such captures fail closed.
74 // Set before Run.74 // Set before Run.
75 NewCaptureRenderer func(w, h int) (ports.Renderer, error)75 NewCaptureRenderer func(w, h int) (ports.Renderer, error)
76+ // StartOff, set before Run, keeps the display off until a scene turns
77+ // it on: a display that reconnects while turned off never lights up.
78+ StartOff bool
76 // capHidden limits a report to what the child renderer of a hidden79 // capHidden limits a report to what the child renderer of a hidden
77 // workspace was given, window by window, while it may still read (its80 // workspace was given, window by window, while it may still read (its
78 // device is not ordered with the display's fences). capped is set when81 // device is not ordered with the display's fences). capped is set when
@@ -888,6 +891,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
888 }891 }
889 }()892 }()
890 o.runContext = ctx893 o.runContext = ctx
894+ o.wantOff = o.StartOff
891 o.observeSecurity()895 o.observeSecurity()
892 r, err := newRenderer(o.Width(), o.Height())896 r, err := newRenderer(o.Width(), o.Height())
893 if err != nil {897 if err != nil {
@@ -1300,6 +1304,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1300 err = o.powerOff()1304 err = o.powerOff()
1301 } else if err = o.modeset(); err == nil {1305 } else if err = o.modeset(); err == nil {
1302 dirty = true1306 dirty = true
1307+ if !o.protected {
1308+ o.probeAsync(r) // skipped while it started off
1309+ }
1303 }1310 }
1304 if err != nil && !o.commitFailed(err, &enabled) {1311 if err != nil && !o.commitFailed(err, &enabled) {
1305 return err1312 return err
M internal/adapters/drm/power_test.go +83 -0
@@ -116,3 +116,86 @@ func TestRunOutputPower(t *testing.T) {
116116 t.Fatal(err)
117117 }
118118 }
119+
120+// An output started off (it reconnected while turned off) never activates
121+// its CRTC before a scene turns it on; its tearing probe, refused by an
122+// inactive CRTC, waits until then.
123+func TestRunOutputStartOff(t *testing.T) {
124+ o, k, commits, commitMu := testOutputMu(t)
125+ o.tearing, o.cursor = true, nil
126+ o.StartOff = true
127+ ready := make(chan error, 1)
128+ o.ready = ready
129+ r := portsmocks.NewMockRenderer(t)
130+ buf := func() ports.DMABuf {
131+ f, w, _ := os.Pipe()
132+ w.Close()
133+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
134+ }
135+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
136+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
137+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
138+ renders := make(chan struct{}, 8)
139+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
140+ renders <- struct{}{}
141+ return nil, nil
142+ }).Maybe()
143+ r.EXPECT().Close().Return().Once()
144+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
145+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
146+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
147+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
148+ snapshot := func() []commitRec {
149+ commitMu.Lock()
150+ defer commitMu.Unlock()
151+ return append([]commitRec(nil), *commits...)
152+ }
153+ lit := func(cs []commitRec) bool {
154+ for _, c := range cs {
155+ if v, ok := c.req.value(tCrtc, pActive); ok && v != 0 && c.flags&atomicTestOnly == 0 {
156+ return true
157+ }
158+ }
159+ return false
160+ }
161+ probed := func(cs []commitRec) bool {
162+ for _, c := range cs {
163+ if c.flags == atomicTestOnly|flipAsyncFlag {
164+ return true
165+ }
166+ }
167+ return false
168+ }
169+ scenes := make(chan ports.Scene, 1)
170+ ctx, cancel := context.WithCancel(context.Background())
171+ done := make(chan error, 1)
172+ go func() {
173+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil)
174+ }()
175+ if err := <-ready; err != nil {
176+ t.Fatal(err)
177+ }
178+ for len(renders) > 0 { // the images are cleared at setup
179+ <-renders
180+ }
181+ off := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1, Off: true}
182+ scenes <- off
183+ time.Sleep(20 * time.Millisecond)
184+ if lit(snapshot()) {
185+ t.Fatal("a display started off lit up")
186+ }
187+ if probed(snapshot()) {
188+ t.Fatal("tearing probed while off")
189+ }
190+ if len(renders) > 0 {
191+ t.Fatal("rendered while off")
192+ }
193+ on := off
194+ on.Off = false
195+ scenes <- on
196+ waitFor(t, func() bool { return lit(snapshot()) && probed(snapshot()) })
197+ cancel()
198+ if err := <-done; err != nil {
199+ t.Fatal(err)
200+ }
201+}
@@ -116,3 +116,86 @@ func TestRunOutputPower(t *testing.T) {
116 t.Fatal(err)116 t.Fatal(err)
117 }117 }
118 }118 }
119+
120+// An output started off (it reconnected while turned off) never activates
121+// its CRTC before a scene turns it on; its tearing probe, refused by an
122+// inactive CRTC, waits until then.
123+func TestRunOutputStartOff(t *testing.T) {
124+ o, k, commits, commitMu := testOutputMu(t)
125+ o.tearing, o.cursor = true, nil
126+ o.StartOff = true
127+ ready := make(chan error, 1)
128+ o.ready = ready
129+ r := portsmocks.NewMockRenderer(t)
130+ buf := func() ports.DMABuf {
131+ f, w, _ := os.Pipe()
132+ w.Close()
133+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
134+ }
135+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
136+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
137+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
138+ renders := make(chan struct{}, 8)
139+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
140+ renders <- struct{}{}
141+ return nil, nil
142+ }).Maybe()
143+ r.EXPECT().Close().Return().Once()
144+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
145+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
146+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
147+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
148+ snapshot := func() []commitRec {
149+ commitMu.Lock()
150+ defer commitMu.Unlock()
151+ return append([]commitRec(nil), *commits...)
152+ }
153+ lit := func(cs []commitRec) bool {
154+ for _, c := range cs {
155+ if v, ok := c.req.value(tCrtc, pActive); ok && v != 0 && c.flags&atomicTestOnly == 0 {
156+ return true
157+ }
158+ }
159+ return false
160+ }
161+ probed := func(cs []commitRec) bool {
162+ for _, c := range cs {
163+ if c.flags == atomicTestOnly|flipAsyncFlag {
164+ return true
165+ }
166+ }
167+ return false
168+ }
169+ scenes := make(chan ports.Scene, 1)
170+ ctx, cancel := context.WithCancel(context.Background())
171+ done := make(chan error, 1)
172+ go func() {
173+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil)
174+ }()
175+ if err := <-ready; err != nil {
176+ t.Fatal(err)
177+ }
178+ for len(renders) > 0 { // the images are cleared at setup
179+ <-renders
180+ }
181+ off := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1, Off: true}
182+ scenes <- off
183+ time.Sleep(20 * time.Millisecond)
184+ if lit(snapshot()) {
185+ t.Fatal("a display started off lit up")
186+ }
187+ if probed(snapshot()) {
188+ t.Fatal("tearing probed while off")
189+ }
190+ if len(renders) > 0 {
191+ t.Fatal("rendered while off")
192+ }
193+ on := off
194+ on.Off = false
195+ scenes <- on
196+ waitFor(t, func() bool { return lit(snapshot()) && probed(snapshot()) })
197+ cancel()
198+ if err := <-done; err != nil {
199+ t.Fatal(err)
200+ }
201+}
M internal/adapters/drm/vrr.go +3 -2
@@ -8,9 +8,10 @@ import (
88
99 // Tearing, variable refresh and content type of frame commits.
1010
11-// probeAsync checks once whether async commits may carry IN_FENCE_FD.
11+// probeAsync checks once whether async commits may carry IN_FENCE_FD. An
12+// inactive CRTC refuses async flips: the probe waits until it is on.
1213 func (o *Output) probeAsync(r ports.Renderer) {
13- if o.asyncProbed || !o.tearing {
14+ if o.asyncProbed || !o.tearing || o.off {
1415 return
1516 }
1617 o.asyncProbed = true
@@ -8,9 +8,10 @@ import (
8 8
9 // Tearing, variable refresh and content type of frame commits.9 // Tearing, variable refresh and content type of frame commits.
10 10
11-// probeAsync checks once whether async commits may carry IN_FENCE_FD.11+// probeAsync checks once whether async commits may carry IN_FENCE_FD. An
12+// inactive CRTC refuses async flips: the probe waits until it is on.
12 func (o *Output) probeAsync(r ports.Renderer) {13 func (o *Output) probeAsync(r ports.Renderer) {
13- if o.asyncProbed || !o.tearing {14+ if o.asyncProbed || !o.tearing || o.off {
14 return15 return
15 }16 }
16 o.asyncProbed = true17 o.asyncProbed = true
M internal/app/drm.go +1 -0
@@ -471,6 +471,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
471471 }
472472 }()
473473 o.NewCaptureRenderer = newRenderer
474+ o.StartOff = set.off[name]
474475 progress.started(name, source)
475476 if cur := o.Cursor(); cur != nil {
476477 curs.set(name, cur)
@@ -471,6 +471,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
471 }471 }
472 }()472 }()
473 o.NewCaptureRenderer = newRenderer473 o.NewCaptureRenderer = newRenderer
474+ o.StartOff = set.off[name]
474 progress.started(name, source)475 progress.started(name, source)
475 if cur := o.Cursor(); cur != nil {476 if cur := o.Cursor(); cur != nil {
476 curs.set(name, cur)477 curs.set(name, cur)
M internal/app/outputrouting_test.go +14 -0
@@ -70,6 +70,20 @@ func TestOutputRouting(t *testing.T) {
7070 }
7171 }
7272
73+// The set remembers which outputs scenes turned off, also once they are
74+// gone: drm starts an output that reconnects off (Output.StartOff).
75+func TestOutputSetRemembersOff(t *testing.T) {
76+ set := newOutputSet(context.Background(), nil)
77+ set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}})
78+ if !set.off["A"] || set.off["B"] {
79+ t.Fatal(set.off)
80+ }
81+ set.scenes([]ports.Scene{{Output: "A"}})
82+ if len(set.off) != 0 {
83+ t.Fatal(set.off)
84+ }
85+}
86+
7387 func TestOutputRoutingAllocations(t *testing.T) {
7488 set := newOutputSet(context.Background(), nil)
7589 // Populate the existing content slot before measuring, so the guard
@@ -70,6 +70,20 @@ func TestOutputRouting(t *testing.T) {
70 }70 }
71 }71 }
72 72
73+// The set remembers which outputs scenes turned off, also once they are
74+// gone: drm starts an output that reconnects off (Output.StartOff).
75+func TestOutputSetRemembersOff(t *testing.T) {
76+ set := newOutputSet(context.Background(), nil)
77+ set.scenes([]ports.Scene{{Output: "A", Off: true}, {Output: "B"}})
78+ if !set.off["A"] || set.off["B"] {
79+ t.Fatal(set.off)
80+ }
81+ set.scenes([]ports.Scene{{Output: "A"}})
82+ if len(set.off) != 0 {
83+ t.Fatal(set.off)
84+ }
85+}
86+
73 func TestOutputRoutingAllocations(t *testing.T) {87 func TestOutputRoutingAllocations(t *testing.T) {
74 set := newOutputSet(context.Background(), nil)88 set := newOutputSet(context.Background(), nil)
75 // Populate the existing content slot before measuring, so the guard89 // Populate the existing content slot before measuring, so the guard
M internal/app/outputs.go +11 -3
@@ -21,8 +21,11 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
2121 // each window is kept so a new output starts with every window drawn.
2222 // Only the goroutine running loop touches the set.
2323 type outputSet struct {
24- outs map[string]*runningOutput
25- latest map[ports.WindowID]ports.SurfaceContent
24+ outs map[string]*runningOutput
25+ latest map[ports.WindowID]ports.SurfaceContent
26+ // off is the latest Scene.Off per output name, kept after the output
27+ // goes: it reconnects off until core's first scene (Output.StartOff).
28+ off map[string]bool
2629 cursor ports.CursorChange
2730 stopped chan outputStopped
2831 captured chan<- ports.CaptureDone
@@ -59,7 +62,7 @@ type runningOutput struct {
5962 }
6063
6164 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
62- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
65+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
6366 }
6467
6568 // start runs an output in its own goroutine and replays the window contents.
@@ -211,6 +214,11 @@ func (s *outputSet) setCursor(c ports.CursorChange) {
211214 // scenes routes each scene to its output, replacing an unread one.
212215 func (s *outputSet) scenes(set []ports.Scene) {
213216 for _, sc := range set {
217+ if sc.Off {
218+ s.off[sc.Output] = true
219+ } else {
220+ delete(s.off, sc.Output)
221+ }
214222 if r := s.outs[sc.Output]; r != nil {
215223 select {
216224 case <-r.scenes:
@@ -21,8 +21,11 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
21 // each window is kept so a new output starts with every window drawn.21 // each window is kept so a new output starts with every window drawn.
22 // Only the goroutine running loop touches the set.22 // Only the goroutine running loop touches the set.
23 type outputSet struct {23 type outputSet struct {
24- outs map[string]*runningOutput24+ outs map[string]*runningOutput
25- latest map[ports.WindowID]ports.SurfaceContent25+ latest map[ports.WindowID]ports.SurfaceContent
26+ // off is the latest Scene.Off per output name, kept after the output
27+ // goes: it reconnects off until core's first scene (Output.StartOff).
28+ off map[string]bool
26 cursor ports.CursorChange29 cursor ports.CursorChange
27 stopped chan outputStopped30 stopped chan outputStopped
28 captured chan<- ports.CaptureDone31 captured chan<- ports.CaptureDone
@@ -59,7 +62,7 @@ type runningOutput struct {
59 }62 }
60 63
61 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {64 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
62- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})}65+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, off: map[string]bool{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
63 }66 }
64 67
65 // start runs an output in its own goroutine and replays the window contents.68 // start runs an output in its own goroutine and replays the window contents.
@@ -211,6 +214,11 @@ func (s *outputSet) setCursor(c ports.CursorChange) {
211 // scenes routes each scene to its output, replacing an unread one.214 // scenes routes each scene to its output, replacing an unread one.
212 func (s *outputSet) scenes(set []ports.Scene) {215 func (s *outputSet) scenes(set []ports.Scene) {
213 for _, sc := range set {216 for _, sc := range set {
217+ if sc.Off {
218+ s.off[sc.Output] = true
219+ } else {
220+ delete(s.off, sc.Output)
221+ }
214 if r := s.outs[sc.Output]; r != nil {222 if r := s.outs[sc.Output]; r != nil {
215 select {223 select {
216 case <-r.scenes:224 case <-r.scenes:
M internal/core/core.go +9 -6
@@ -79,12 +79,15 @@ type Core struct {
7979 cfg ports.Config
8080 screens []*screen
8181 focusScreen int
82- binds map[binding]Action
83- pressed map[string]bool
84- configures configures
85- pointer WindowID
86- grab WindowID
87- buttons map[uint32]bool
82+ // offGone names outputs unplugged while turned off: a display that
83+ // reconnects from deep sleep comes back off, until the next input.
84+ offGone map[string]bool
85+ binds map[binding]Action
86+ pressed map[string]bool
87+ configures configures
88+ pointer WindowID
89+ grab WindowID
90+ buttons map[uint32]bool
8891 // drag is the pointer drag in progress (drag.go); swallow holds the
8992 // buttons whose release no client must see. mods are the modifiers
9093 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
@@ -79,12 +79,15 @@ type Core struct {
79 cfg ports.Config79 cfg ports.Config
80 screens []*screen80 screens []*screen
81 focusScreen int81 focusScreen int
82- binds map[binding]Action82+ // offGone names outputs unplugged while turned off: a display that
83- pressed map[string]bool83+ // reconnects from deep sleep comes back off, until the next input.
84- configures configures84+ offGone map[string]bool
85- pointer WindowID85+ binds map[binding]Action
86- grab WindowID86+ pressed map[string]bool
87- buttons map[uint32]bool87+ configures configures
88+ pointer WindowID
89+ grab WindowID
90+ buttons map[uint32]bool
88 // drag is the pointer drag in progress (drag.go); swallow holds the91 // drag is the pointer drag in progress (drag.go); swallow holds the
89 // buttons whose release no client must see. mods are the modifiers92 // buttons whose release no client must see. mods are the modifiers
90 // held, cmdMod the one keyboard.cmd names. lastButton is the latest93 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
M internal/core/power.go +3 -0
@@ -9,7 +9,10 @@ import (
99
1010 // userActivity tells wayland the user touched an input device, at most
1111 // once per ports.ActivityInterval: idle notification timers restart.
12+// Input also forgets the outputs unplugged while off: the user is back, so
13+// they reconnect on.
1214 func (c *Core) userActivity(ctx context.Context) error {
15+ clear(c.offGone)
1316 now := time.Now()
1417 if now.Sub(c.activity) < ports.ActivityInterval {
1518 return nil
@@ -9,7 +9,10 @@ import (
9 9
10 // userActivity tells wayland the user touched an input device, at most10 // userActivity tells wayland the user touched an input device, at most
11 // once per ports.ActivityInterval: idle notification timers restart.11 // once per ports.ActivityInterval: idle notification timers restart.
12+// Input also forgets the outputs unplugged while off: the user is back, so
13+// they reconnect on.
12 func (c *Core) userActivity(ctx context.Context) error {14 func (c *Core) userActivity(ctx context.Context) error {
15+ clear(c.offGone)
13 now := time.Now()16 now := time.Now()
14 if now.Sub(c.activity) < ports.ActivityInterval {17 if now.Sub(c.activity) < ports.ActivityInterval {
15 return nil18 return nil
M internal/core/power_test.go +68 -0
@@ -73,6 +73,74 @@ func TestOutputPower(t *testing.T) {
7373 sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off })
7474 }
7575
76+// An output unplugged while off (a display in deep sleep) reconnects off;
77+// after input it reconnects on. Alone, it is the last output: core keeps a
78+// placeholder screen meanwhile.
79+func TestOutputReconnectedWhileOff(t *testing.T) {
80+ for _, alone := range []bool{false, true} {
81+ t.Run(map[bool]string{false: "beside another", true: "alone"}[alone], func(t *testing.T) {
82+ input, client, output, commands, scenes := powerCore(t)
83+ stop := make(chan struct{})
84+ defer close(stop)
85+ active := make(chan struct{}, 1)
86+ go func() { // core blocks on a full command channel
87+ for {
88+ select {
89+ case c := <-commands:
90+ if _, ok := c.(ports.UserActivity); ok {
91+ active <- struct{}{}
92+ }
93+ case <-stop:
94+ return
95+ }
96+ }
97+ }()
98+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
99+ if alone {
100+ output <- ports.OutputRemoved{Name: "OUT-1"}
101+ gone(t, scenes, "OUT-1")
102+ }
103+ client <- ports.OutputPower{Output: "OUT-2", On: false}
104+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
105+ output <- ports.OutputRemoved{Name: "OUT-2"}
106+ gone(t, scenes, "OUT-2") // no older scene can answer below
107+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
108+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
109+
110+ client <- ports.OutputPower{Output: "OUT-2", On: true}
111+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
112+ client <- ports.OutputPower{Output: "OUT-2", On: false}
113+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
114+ output <- ports.OutputRemoved{Name: "OUT-2"}
115+ gone(t, scenes, "OUT-2")
116+ input <- ports.PointerMotion{X: 1, Y: 1}
117+ select { // input and outputs are separate channels: order them
118+ case <-active:
119+ case <-time.After(2 * time.Second):
120+ t.Fatal("input not handled")
121+ }
122+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
123+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
124+ })
125+ }
126+}
127+
128+// gone waits for a scene set without the named output.
129+func gone(t *testing.T, scenes <-chan []ports.Scene, name string) {
130+ t.Helper()
131+ deadline := time.After(2 * time.Second)
132+ for {
133+ select {
134+ case set := <-scenes:
135+ if !slices.ContainsFunc(set, func(s ports.Scene) bool { return s.Output == name }) {
136+ return
137+ }
138+ case <-deadline:
139+ t.Fatalf("%s still in the scenes", name)
140+ }
141+ }
142+}
143+
76144 // Input reports user activity to wayland, at most once per interval.
77145 func TestUserActivity(t *testing.T) {
78146 input, _, _, commands, _ := powerCore(t)
@@ -73,6 +73,74 @@ func TestOutputPower(t *testing.T) {
73 sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off })73 sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off })
74 }74 }
75 75
76+// An output unplugged while off (a display in deep sleep) reconnects off;
77+// after input it reconnects on. Alone, it is the last output: core keeps a
78+// placeholder screen meanwhile.
79+func TestOutputReconnectedWhileOff(t *testing.T) {
80+ for _, alone := range []bool{false, true} {
81+ t.Run(map[bool]string{false: "beside another", true: "alone"}[alone], func(t *testing.T) {
82+ input, client, output, commands, scenes := powerCore(t)
83+ stop := make(chan struct{})
84+ defer close(stop)
85+ active := make(chan struct{}, 1)
86+ go func() { // core blocks on a full command channel
87+ for {
88+ select {
89+ case c := <-commands:
90+ if _, ok := c.(ports.UserActivity); ok {
91+ active <- struct{}{}
92+ }
93+ case <-stop:
94+ return
95+ }
96+ }
97+ }()
98+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
99+ if alone {
100+ output <- ports.OutputRemoved{Name: "OUT-1"}
101+ gone(t, scenes, "OUT-1")
102+ }
103+ client <- ports.OutputPower{Output: "OUT-2", On: false}
104+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
105+ output <- ports.OutputRemoved{Name: "OUT-2"}
106+ gone(t, scenes, "OUT-2") // no older scene can answer below
107+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
108+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
109+
110+ client <- ports.OutputPower{Output: "OUT-2", On: true}
111+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
112+ client <- ports.OutputPower{Output: "OUT-2", On: false}
113+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
114+ output <- ports.OutputRemoved{Name: "OUT-2"}
115+ gone(t, scenes, "OUT-2")
116+ input <- ports.PointerMotion{X: 1, Y: 1}
117+ select { // input and outputs are separate channels: order them
118+ case <-active:
119+ case <-time.After(2 * time.Second):
120+ t.Fatal("input not handled")
121+ }
122+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
123+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
124+ })
125+ }
126+}
127+
128+// gone waits for a scene set without the named output.
129+func gone(t *testing.T, scenes <-chan []ports.Scene, name string) {
130+ t.Helper()
131+ deadline := time.After(2 * time.Second)
132+ for {
133+ select {
134+ case set := <-scenes:
135+ if !slices.ContainsFunc(set, func(s ports.Scene) bool { return s.Output == name }) {
136+ return
137+ }
138+ case <-deadline:
139+ t.Fatalf("%s still in the scenes", name)
140+ }
141+ }
142+}
143+
76 // Input reports user activity to wayland, at most once per interval.144 // Input reports user activity to wayland, at most once per interval.
77 func TestUserActivity(t *testing.T) {145 func TestUserActivity(t *testing.T) {
78 input, _, _, commands, _ := powerCore(t)146 input, _, _, commands, _ := powerCore(t)
M internal/core/screens.go +8 -0
@@ -196,6 +196,8 @@ func (c *Core) addScreen(info ports.OutputInfo) {
196196 s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
197197 c.screens = append(c.screens, s)
198198 }
199+ s.off = c.offGone[info.Name]
200+ delete(c.offGone, info.Name)
199201 s.mon.Name, s.mon.Key = info.Name, info.Key()
200202 c.settings(s.mon)
201203 s.cfgScale = c.configScale(info.Name)
@@ -224,6 +226,12 @@ func (c *Core) removeScreen(name string) {
224226 if c.configures.cw.sc == c.screens[i] {
225227 c.configures.cw.reset()
226228 }
229+ if c.screens[i].off {
230+ if c.offGone == nil {
231+ c.offGone = map[string]bool{}
232+ }
233+ c.offGone[name] = true
234+ }
227235 if len(c.screens) == 1 {
228236 c.screens[0].info = ports.OutputInfo{}
229237 c.screens[0].layers = nil
@@ -196,6 +196,8 @@ func (c *Core) addScreen(info ports.OutputInfo) {
196 s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}196 s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
197 c.screens = append(c.screens, s)197 c.screens = append(c.screens, s)
198 }198 }
199+ s.off = c.offGone[info.Name]
200+ delete(c.offGone, info.Name)
199 s.mon.Name, s.mon.Key = info.Name, info.Key()201 s.mon.Name, s.mon.Key = info.Name, info.Key()
200 c.settings(s.mon)202 c.settings(s.mon)
201 s.cfgScale = c.configScale(info.Name)203 s.cfgScale = c.configScale(info.Name)
@@ -224,6 +226,12 @@ func (c *Core) removeScreen(name string) {
224 if c.configures.cw.sc == c.screens[i] {226 if c.configures.cw.sc == c.screens[i] {
225 c.configures.cw.reset()227 c.configures.cw.reset()
226 }228 }
229+ if c.screens[i].off {
230+ if c.offGone == nil {
231+ c.offGone = map[string]bool{}
232+ }
233+ c.offGone[name] = true
234+ }
227 if len(c.screens) == 1 {235 if len(c.screens) == 1 {
228 c.screens[0].info = ports.OutputInfo{}236 c.screens[0].info = ports.OutputInfo{}
229 c.screens[0].layers = nil237 c.screens[0].layers = nil

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 23

Tags 10

↑↓ moveenter openesc close