detached e55c0e7

Merge pull request #3 from bnema/idle-power

feat(wayland): ext_idle_notifier_v1 and wlr output power management
commit
e55c0e7b602f471af0e1d1c7a368962fceee81fd
author
bnema · · 2026-09-26 18:37 UTC
committer
GitHub · · 2026-09-26 18:37 UTC
merge of
49463ee + 7bfbe4f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
17 files · +814 -28
M README.md +16 -0
@@ -143,6 +143,22 @@ xwayland = off # no X11 display
143143
144144 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
145145
146+## Idle and screen off
147+
148+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.
149+
150+```text
151+startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
152+```
153+
154+`swayidle` runs its commands through a shell; NeferWL does not, so the quoted form `timeout 300 'wlopm --off "*"'` does not fit on a `startup` line. Put each command in a script (see [Running commands](#running-commands)):
155+
156+```sh
157+#!/bin/sh
158+# ~/.local/bin/wlopm-off (wlopm-on is the same with --on)
159+exec wlopm --off '*'
160+```
161+
146162 ## State for scripts
147163
148164 While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists:
@@ -143,6 +143,22 @@ xwayland = off # no X11 display
143 143
144 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.144 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
145 145
146+## Idle and screen off
147+
148+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.
149+
150+```text
151+startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
152+```
153+
154+`swayidle` runs its commands through a shell; NeferWL does not, so the quoted form `timeout 300 'wlopm --off "*"'` does not fit on a `startup` line. Put each command in a script (see [Running commands](#running-commands)):
155+
156+```sh
157+#!/bin/sh
158+# ~/.local/bin/wlopm-off (wlopm-on is the same with --on)
159+exec wlopm --off '*'
160+```
161+
146 ## State for scripts162 ## State for scripts
147 163
148 While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists:164 While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists:
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.2.0
7+ github.com/bnema/purego-libwayland v0.2.1-0.20260926175222-2747fb8b4d43
88 github.com/bnema/purego-vulkan v0.4.1
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
@@ -4,7 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.46 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.2.07+ github.com/bnema/purego-libwayland v0.2.1-0.20260926175222-2747fb8b4d43
8 github.com/bnema/purego-vulkan v0.4.18 github.com/bnema/purego-vulkan v0.4.1
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -0 generated file: collapsed
M internal/adapters/drm/output.go +49 -8
@@ -95,6 +95,10 @@ type Output struct {
9595 vrrProp uint32
9696 vrrOn bool
9797 composedSince time.Time
98+ // wantOff is the latest Scene.Off: a client turned the display off.
99+ // off: the CRTC is inactive for it. Every modeset (resume, recovery)
100+ // follows wantOff, so a display turned off never lights up.
101+ wantOff, off bool
98102 // kind is how the images were made.
99103 kind imageKind
100104 // formats receives the direct scanout formats after each modeset;
@@ -410,7 +414,7 @@ func (o *Output) modeset() error {
410414 if err != nil {
411415 return err
412416 }
413- if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {
417+ if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil {
414418 _ = o.k.destroyBlob(blob)
415419 return fmt.Errorf("modeset: %w", err)
416420 }
@@ -423,17 +427,35 @@ func (o *Output) modeset() error {
423427 _ = o.k.destroyBlob(o.modeBlob)
424428 }
425429 o.modeBlob = blob
426- o.vrrOn, o.overlayOn = false, 0
430+ o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff
427431 if o.cursor != nil {
428432 o.cursor.applied = cursorState{}
429433 o.cursor.screen, o.cursor.flying = 0, false
430434 }
431- o.log.Info().Str("connector", o.conn.name).Msg("modeset")
432- o.testCursor()
435+ o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
436+ if !o.off {
437+ // An inactive CRTC tells nothing about the cursor plane.
438+ o.testCursor()
439+ }
433440 o.sendFormats()
434441 return nil
435442 }
436443
444+// powerOff turns the display off: the CRTC goes inactive and keeps its
445+// mode, as DPMS off. The commit blocks, so no event is pending after it;
446+// a modeset turns the display back on.
447+func (o *Output) powerOff() error {
448+ req := &atomicReq{}
449+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
450+ req.set(o.crtc, o.vrrProp, 0)
451+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
452+ return fmt.Errorf("power off: %w", err)
453+ }
454+ o.off, o.vrrOn = true, false
455+ o.log.Info().Str("connector", o.conn.name).Msg("power off")
456+ return nil
457+}
458+
437459 // sendFormats reports the formats clients may allocate for direct
438460 // scanout on this output (none when scanout is off). It never blocks:
439461 // wayland keeps the latest per output, a dropped report is resent at the
@@ -461,7 +483,8 @@ func (o *Output) testModeset() error {
461483 return err
462484 }
463485 defer func() { _ = o.k.destroyBlob(blob) }()
464- if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {
486+ // Tested active: an inactive CRTC would accept any image.
487+ if err := o.k.commit(o.modesetReq(blob, true), atomicTestOnly|atomicAllowModes, 0); err != nil {
465488 return fmt.Errorf("modeset test: %w", err)
466489 }
467490 return nil
@@ -495,11 +518,12 @@ func (o *Output) cursorOff(err error) {
495518 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
496519 }
497520
498-// modesetReq is the modeset of the front image with mode blob.
499-func (o *Output) modesetReq(blob uint32) *atomicReq {
521+// modesetReq is the modeset of the front image with mode blob; active
522+// false keeps the display off (output power).
523+func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
500524 req := &atomicReq{}
501525 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
502- req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
526+ req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
503527 req.set(o.crtc, o.vrrProp, 0)
504528 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
505529 o.primaryProps(req, o.fbs[1-o.back])
@@ -947,6 +971,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
947971 stateDirty = true
948972 }
949973 scene, haveScene, dirty = s, true, true
974+ o.wantOff = s.Off
950975 case c := <-cursor:
951976 want = c
952977 // Before the first scene the scale is unknown: loaded then.
@@ -978,6 +1003,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9781003 if !enabled || o.pending {
9791004 continue
9801005 }
1006+ // Output power: off waits for no commit in flight; on is a modeset
1007+ // and a full frame.
1008+ if o.wantOff != o.off {
1009+ var err error
1010+ if o.wantOff {
1011+ err = o.powerOff()
1012+ } else if err = o.modeset(); err == nil {
1013+ dirty = true
1014+ }
1015+ if err != nil && !o.commitFailed(err, &enabled) {
1016+ return err
1017+ }
1018+ }
1019+ if o.off {
1020+ continue
1021+ }
9811022 if !dirty || !haveScene {
9821023 if stateDirty {
9831024 err := o.commitState(o.wantVRR(o.shown != 0))
@@ -95,6 +95,10 @@ type Output struct {
95 vrrProp uint3295 vrrProp uint32
96 vrrOn bool96 vrrOn bool
97 composedSince time.Time97 composedSince time.Time
98+ // wantOff is the latest Scene.Off: a client turned the display off.
99+ // off: the CRTC is inactive for it. Every modeset (resume, recovery)
100+ // follows wantOff, so a display turned off never lights up.
101+ wantOff, off bool
98 // kind is how the images were made.102 // kind is how the images were made.
99 kind imageKind103 kind imageKind
100 // formats receives the direct scanout formats after each modeset;104 // formats receives the direct scanout formats after each modeset;
@@ -410,7 +414,7 @@ func (o *Output) modeset() error {
410 if err != nil {414 if err != nil {
411 return err415 return err
412 }416 }
413- if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {417+ if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil {
414 _ = o.k.destroyBlob(blob)418 _ = o.k.destroyBlob(blob)
415 return fmt.Errorf("modeset: %w", err)419 return fmt.Errorf("modeset: %w", err)
416 }420 }
@@ -423,17 +427,35 @@ func (o *Output) modeset() error {
423 _ = o.k.destroyBlob(o.modeBlob)427 _ = o.k.destroyBlob(o.modeBlob)
424 }428 }
425 o.modeBlob = blob429 o.modeBlob = blob
426- o.vrrOn, o.overlayOn = false, 0430+ o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff
427 if o.cursor != nil {431 if o.cursor != nil {
428 o.cursor.applied = cursorState{}432 o.cursor.applied = cursorState{}
429 o.cursor.screen, o.cursor.flying = 0, false433 o.cursor.screen, o.cursor.flying = 0, false
430 }434 }
431- o.log.Info().Str("connector", o.conn.name).Msg("modeset")435+ o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
432- o.testCursor()436+ if !o.off {
437+ // An inactive CRTC tells nothing about the cursor plane.
438+ o.testCursor()
439+ }
433 o.sendFormats()440 o.sendFormats()
434 return nil441 return nil
435 }442 }
436 443
444+// powerOff turns the display off: the CRTC goes inactive and keeps its
445+// mode, as DPMS off. The commit blocks, so no event is pending after it;
446+// a modeset turns the display back on.
447+func (o *Output) powerOff() error {
448+ req := &atomicReq{}
449+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
450+ req.set(o.crtc, o.vrrProp, 0)
451+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
452+ return fmt.Errorf("power off: %w", err)
453+ }
454+ o.off, o.vrrOn = true, false
455+ o.log.Info().Str("connector", o.conn.name).Msg("power off")
456+ return nil
457+}
458+
437 // sendFormats reports the formats clients may allocate for direct459 // sendFormats reports the formats clients may allocate for direct
438 // scanout on this output (none when scanout is off). It never blocks:460 // scanout on this output (none when scanout is off). It never blocks:
439 // wayland keeps the latest per output, a dropped report is resent at the461 // wayland keeps the latest per output, a dropped report is resent at the
@@ -461,7 +483,8 @@ func (o *Output) testModeset() error {
461 return err483 return err
462 }484 }
463 defer func() { _ = o.k.destroyBlob(blob) }()485 defer func() { _ = o.k.destroyBlob(blob) }()
464- if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {486+ // Tested active: an inactive CRTC would accept any image.
487+ if err := o.k.commit(o.modesetReq(blob, true), atomicTestOnly|atomicAllowModes, 0); err != nil {
465 return fmt.Errorf("modeset test: %w", err)488 return fmt.Errorf("modeset test: %w", err)
466 }489 }
467 return nil490 return nil
@@ -495,11 +518,12 @@ func (o *Output) cursorOff(err error) {
495 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")518 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
496 }519 }
497 520
498-// modesetReq is the modeset of the front image with mode blob.521+// modesetReq is the modeset of the front image with mode blob; active
499-func (o *Output) modesetReq(blob uint32) *atomicReq {522+// false keeps the display off (output power).
523+func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
500 req := &atomicReq{}524 req := &atomicReq{}
501 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))525 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
502- req.set(o.crtc, o.crtcProps["ACTIVE"], 1)526+ req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
503 req.set(o.crtc, o.vrrProp, 0)527 req.set(o.crtc, o.vrrProp, 0)
504 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))528 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
505 o.primaryProps(req, o.fbs[1-o.back])529 o.primaryProps(req, o.fbs[1-o.back])
@@ -947,6 +971,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
947 stateDirty = true971 stateDirty = true
948 }972 }
949 scene, haveScene, dirty = s, true, true973 scene, haveScene, dirty = s, true, true
974+ o.wantOff = s.Off
950 case c := <-cursor:975 case c := <-cursor:
951 want = c976 want = c
952 // Before the first scene the scale is unknown: loaded then.977 // Before the first scene the scale is unknown: loaded then.
@@ -978,6 +1003,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
978 if !enabled || o.pending {1003 if !enabled || o.pending {
979 continue1004 continue
980 }1005 }
1006+ // Output power: off waits for no commit in flight; on is a modeset
1007+ // and a full frame.
1008+ if o.wantOff != o.off {
1009+ var err error
1010+ if o.wantOff {
1011+ err = o.powerOff()
1012+ } else if err = o.modeset(); err == nil {
1013+ dirty = true
1014+ }
1015+ if err != nil && !o.commitFailed(err, &enabled) {
1016+ return err
1017+ }
1018+ }
1019+ if o.off {
1020+ continue
1021+ }
981 if !dirty || !haveScene {1022 if !dirty || !haveScene {
982 if stateDirty {1023 if stateDirty {
983 err := o.commitState(o.wantVRR(o.shown != 0))1024 err := o.commitState(o.wantVRR(o.shown != 0))
A internal/adapters/drm/power_test.go +117 -0
@@ -0,0 +1,117 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// A scene with Off turns the CRTC inactive and draws nothing; the next
15+// scene without Off modesets (ACTIVE=1) and draws a frame.
16+func TestRunOutputPower(t *testing.T) {
17+ o, k, commits, commitMu := testOutputMu(t)
18+ o.tearing, o.cursor = false, nil
19+ flips := make(chan flipEvent, 1)
20+ o.flipped = flips
21+ r := portsmocks.NewMockRenderer(t)
22+ buf := func() ports.DMABuf {
23+ f, w, _ := os.Pipe()
24+ w.Close()
25+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
26+ }
27+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
28+ r.EXPECT().UseTarget(mock.Anything).Return()
29+ renders := make(chan struct{}, 8)
30+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
31+ renders <- struct{}{}
32+ return nil, nil
33+ })
34+ r.EXPECT().Close().Return().Once()
35+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
36+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
37+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
38+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
39+ snapshot := func() []commitRec {
40+ commitMu.Lock()
41+ defer commitMu.Unlock()
42+ return append([]commitRec(nil), *commits...)
43+ }
44+ active := func(c commitRec) (uint64, bool) { return c.req.value(tCrtc, pActive) }
45+ scenes := make(chan ports.Scene, 1)
46+ vt := make(chan bool)
47+ ctx, cancel := context.WithCancel(context.Background())
48+ done := make(chan error, 1)
49+ go func() {
50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))
51+ }()
52+ on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
53+ scenes <- on
54+ <-renders
55+ var frame commitRec
56+ waitFor(t, func() bool {
57+ for _, c := range snapshot() {
58+ if c.user&3 == userFrame {
59+ frame = c
60+ return true
61+ }
62+ }
63+ return false
64+ })
65+ flips <- eventOf(frame)
66+
67+ off := on
68+ off.Off = true
69+ scenes <- off
70+ waitFor(t, func() bool {
71+ cs := snapshot()
72+ v, ok := active(cs[len(cs)-1])
73+ return ok && v == 0
74+ })
75+ n := len(snapshot())
76+ // Scenes while off draw nothing (renders before the power off drained).
77+ for len(renders) > 0 {
78+ <-renders
79+ }
80+ scenes <- off
81+ time.Sleep(20 * time.Millisecond)
82+ select {
83+ case <-renders:
84+ t.Fatal("rendered while off")
85+ default:
86+ }
87+ if len(snapshot()) != n {
88+ t.Fatal("committed while off")
89+ }
90+
91+ // A VT switch back modesets while off: the CRTC stays inactive.
92+ vt <- false
93+ vt <- true
94+ waitFor(t, func() bool { return len(snapshot()) > n })
95+ time.Sleep(20 * time.Millisecond)
96+ for _, c := range snapshot()[n:] {
97+ if v, ok := active(c); ok && v != 0 && c.flags&atomicTestOnly == 0 {
98+ t.Fatal("VT resume turned the display on")
99+ }
100+ }
101+ n = len(snapshot())
102+
103+ scenes <- on
104+ <-renders
105+ waitFor(t, func() bool {
106+ for _, c := range snapshot()[n:] {
107+ if v, ok := active(c); ok && v == 1 {
108+ return true
109+ }
110+ }
111+ return false
112+ })
113+ cancel()
114+ if err := <-done; err != nil {
115+ t.Fatal(err)
116+ }
117+}
@@ -0,0 +1,117 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+// A scene with Off turns the CRTC inactive and draws nothing; the next
15+// scene without Off modesets (ACTIVE=1) and draws a frame.
16+func TestRunOutputPower(t *testing.T) {
17+ o, k, commits, commitMu := testOutputMu(t)
18+ o.tearing, o.cursor = false, nil
19+ flips := make(chan flipEvent, 1)
20+ o.flipped = flips
21+ r := portsmocks.NewMockRenderer(t)
22+ buf := func() ports.DMABuf {
23+ f, w, _ := os.Pipe()
24+ w.Close()
25+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
26+ }
27+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
28+ r.EXPECT().UseTarget(mock.Anything).Return()
29+ renders := make(chan struct{}, 8)
30+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
31+ renders <- struct{}{}
32+ return nil, nil
33+ })
34+ r.EXPECT().Close().Return().Once()
35+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
36+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
37+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
38+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
39+ snapshot := func() []commitRec {
40+ commitMu.Lock()
41+ defer commitMu.Unlock()
42+ return append([]commitRec(nil), *commits...)
43+ }
44+ active := func(c commitRec) (uint64, bool) { return c.req.value(tCrtc, pActive) }
45+ scenes := make(chan ports.Scene, 1)
46+ vt := make(chan bool)
47+ ctx, cancel := context.WithCancel(context.Background())
48+ done := make(chan error, 1)
49+ go func() {
50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))
51+ }()
52+ on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
53+ scenes <- on
54+ <-renders
55+ var frame commitRec
56+ waitFor(t, func() bool {
57+ for _, c := range snapshot() {
58+ if c.user&3 == userFrame {
59+ frame = c
60+ return true
61+ }
62+ }
63+ return false
64+ })
65+ flips <- eventOf(frame)
66+
67+ off := on
68+ off.Off = true
69+ scenes <- off
70+ waitFor(t, func() bool {
71+ cs := snapshot()
72+ v, ok := active(cs[len(cs)-1])
73+ return ok && v == 0
74+ })
75+ n := len(snapshot())
76+ // Scenes while off draw nothing (renders before the power off drained).
77+ for len(renders) > 0 {
78+ <-renders
79+ }
80+ scenes <- off
81+ time.Sleep(20 * time.Millisecond)
82+ select {
83+ case <-renders:
84+ t.Fatal("rendered while off")
85+ default:
86+ }
87+ if len(snapshot()) != n {
88+ t.Fatal("committed while off")
89+ }
90+
91+ // A VT switch back modesets while off: the CRTC stays inactive.
92+ vt <- false
93+ vt <- true
94+ waitFor(t, func() bool { return len(snapshot()) > n })
95+ time.Sleep(20 * time.Millisecond)
96+ for _, c := range snapshot()[n:] {
97+ if v, ok := active(c); ok && v != 0 && c.flags&atomicTestOnly == 0 {
98+ t.Fatal("VT resume turned the display on")
99+ }
100+ }
101+ n = len(snapshot())
102+
103+ scenes <- on
104+ <-renders
105+ waitFor(t, func() bool {
106+ for _, c := range snapshot()[n:] {
107+ if v, ok := active(c); ok && v == 1 {
108+ return true
109+ }
110+ }
111+ return false
112+ })
113+ cancel()
114+ if err := <-done; err != nil {
115+ t.Fatal(err)
116+ }
117+}
M internal/adapters/headless/output.go +3 -2
@@ -105,8 +105,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
105105 break drain
106106 }
107107 }
108- if !haveScene || !dirty {
109- // Contents not drawn are still read: report them.
108+ if !haveScene || !dirty || scene.Off {
109+ // Contents not drawn are still read: report them. An output
110+ // turned off draws nothing.
110111 pending = opts.report(pending, seen)
111112 continue
112113 }
@@ -105,8 +105,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
105 break drain105 break drain
106 }106 }
107 }107 }
108- if !haveScene || !dirty {108+ if !haveScene || !dirty || scene.Off {
109- // Contents not drawn are still read: report them.109+ // Contents not drawn are still read: report them. An output
110+ // turned off draws nothing.
110 pending = opts.report(pending, seen)111 pending = opts.report(pending, seen)
111 continue112 continue
112 }113 }
M internal/adapters/wayland/globals.go +1 -0
@@ -22,6 +22,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2222 func() error { return registerPresentation(d, s) },
2323 func() error { return registerSyncobj(d, s) },
2424 func() error { return registerInhibit(d, s) },
25+ func() error { return registerIdle(d, s) },
2526 func() error { return registerActivation(d, s) },
2627 func() error { return registerVirtualKeyboard(d, s) },
2728 func() error {
@@ -22,6 +22,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
22 func() error { return registerPresentation(d, s) },22 func() error { return registerPresentation(d, s) },
23 func() error { return registerSyncobj(d, s) },23 func() error { return registerSyncobj(d, s) },
24 func() error { return registerInhibit(d, s) },24 func() error { return registerInhibit(d, s) },
25+ func() error { return registerIdle(d, s) },
25 func() error { return registerActivation(d, s) },26 func() error { return registerActivation(d, s) },
26 func() error { return registerVirtualKeyboard(d, s) },27 func() error { return registerVirtualKeyboard(d, s) },
27 func() error {28 func() error {
A internal/adapters/wayland/idle.go +219 -0
@@ -0,0 +1,219 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/extidlenotify"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/wlroutputpower"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// Idle and output power. ext_idle_notifier_v1: each notification has its
14+// own timeout, restarted by user activity (ports.UserActivity from core);
15+// idle inhibitors hold every notification but input-idle ones.
16+// zwlr_output_power_v1: a client such as wlopm turns a display off or on;
17+// core decides and the scene carries it.
18+
19+// idleNotification is one ext_idle_notification_v1.
20+type idleNotification struct {
21+ res *extidlenotify.ExtIdleNotificationV1
22+ timeout time.Duration
23+ input bool // get_input_idle_notification: ignores idle inhibitors
24+ idle bool // idled sent, resumed not yet
25+ timer *time.Timer
26+ gen uint64 // bumped on stop: a firing timer of an older gen is stale
27+}
28+
29+// outputPower is one zwlr_output_power_v1.
30+type outputPower struct {
31+ res *wlroutputpower.ZwlrOutputPowerV1
32+ output string
33+}
34+
35+func registerIdle(d *server.Display, s *Server) error {
36+ if err := extidlenotify.NewExtIdleNotifierV1Global(d, 2, func(c server.Client, v, id uint32) {
37+ _, _ = extidlenotify.NewExtIdleNotifierV1(c, int32(v), id, idleNotifier{s})
38+ }); err != nil {
39+ return err
40+ }
41+ return wlroutputpower.NewZwlrOutputPowerManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
42+ _, _ = wlroutputpower.NewZwlrOutputPowerManagerV1(c, int32(v), id, powerManager{s})
43+ })
44+}
45+
46+type idleNotifier struct{ server *Server }
47+
48+func (idleNotifier) Destroy(*extidlenotify.ExtIdleNotifierV1) {}
49+
50+func (n idleNotifier) GetIdleNotification(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, _ *wayland.Seat) {
51+ n.server.addIdle(r, id, timeout, false)
52+}
53+
54+func (n idleNotifier) GetInputIdleNotification(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, _ *wayland.Seat) {
55+ n.server.addIdle(r, id, timeout, true)
56+}
57+
58+type idleNotificationHandler struct{}
59+
60+func (idleNotificationHandler) Destroy(*extidlenotify.ExtIdleNotificationV1) {}
61+
62+func (s *Server) addIdle(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, input bool) {
63+ res, err := extidlenotify.NewExtIdleNotificationV1(r.Client(), r.Version(), id, idleNotificationHandler{})
64+ if err != nil {
65+ return
66+ }
67+ n := &idleNotification{res: res, timeout: time.Duration(timeout) * time.Millisecond, input: input}
68+ s.idleNotes = append(s.idleNotes, n)
69+ res.OnDestroy = func() {
70+ n.stop()
71+ for i, x := range s.idleNotes {
72+ if x == n {
73+ s.idleNotes = append(s.idleNotes[:i], s.idleNotes[i+1:]...)
74+ break
75+ }
76+ }
77+ }
78+ s.armIdle(n)
79+}
80+
81+// idleHeld reports whether an idle inhibitor keeps n from firing.
82+func (s *Server) idleHeld(n *idleNotification) bool {
83+ return !n.input && len(s.idleWindows) > 0
84+}
85+
86+// armIdle restarts n's timer, unless an inhibitor holds it. The timer
87+// fires on the display goroutine.
88+func (s *Server) armIdle(n *idleNotification) {
89+ n.stop()
90+ if s.idleHeld(n) {
91+ return
92+ }
93+ gen := n.gen
94+ // Core reports activity at most once per ActivityInterval: a shorter
95+ // timeout would fire while the user is still typing.
96+ n.timer = time.AfterFunc(max(n.timeout, ports.ActivityInterval), func() {
97+ s.display.Do(func() {
98+ // A timer stopped or replaced while it waited for Do is stale.
99+ if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
100+ return
101+ }
102+ n.idle = true
103+ n.res.SendIdled()
104+ })
105+ })
106+}
107+
108+func (n *idleNotification) stop() {
109+ n.gen++
110+ if n.timer != nil {
111+ n.timer.Stop()
112+ n.timer = nil
113+ }
114+}
115+
116+// userActivity resumes idle notifications and restarts their timers.
117+func (s *Server) userActivity() {
118+ for _, n := range s.idleNotes {
119+ if n.idle {
120+ n.idle = false
121+ n.res.SendResumed()
122+ }
123+ s.armIdle(n)
124+ }
125+}
126+
127+// syncIdle follows idle inhibitors: while one exists, inhibitable timers
128+// stop; once the last one goes, they start again from zero.
129+func (s *Server) syncIdle(wasHeld bool) {
130+ if held := len(s.idleWindows) > 0; held != wasHeld {
131+ for _, n := range s.idleNotes {
132+ if !n.input {
133+ s.armIdle(n)
134+ }
135+ }
136+ }
137+}
138+
139+type powerManager struct{ server *Server }
140+
141+func (powerManager) Destroy(*wlroutputpower.ZwlrOutputPowerManagerV1) {}
142+
143+func (m powerManager) GetOutputPower(r *wlroutputpower.ZwlrOutputPowerManagerV1, id uint32, wl *wayland.Output) {
144+ s := m.server
145+ o := s.outputOf(wl)
146+ p := &outputPower{}
147+ res, err := wlroutputpower.NewZwlrOutputPowerV1(r.Client(), r.Version(), id, powerHandler{s, p})
148+ if err != nil {
149+ return
150+ }
151+ p.res = res
152+ if o == nil {
153+ // Unplugged or unknown: the object is inert.
154+ res.SendFailed()
155+ return
156+ }
157+ p.output = o.name()
158+ s.powers = append(s.powers, p)
159+ res.OnDestroy = func() {
160+ for i, x := range s.powers {
161+ if x == p {
162+ s.powers = append(s.powers[:i], s.powers[i+1:]...)
163+ break
164+ }
165+ }
166+ }
167+ res.SendMode(powerMode(!s.outputsOff[p.output]))
168+}
169+
170+type powerHandler struct {
171+ server *Server
172+ p *outputPower
173+}
174+
175+func (powerHandler) Destroy(*wlroutputpower.ZwlrOutputPowerV1) {}
176+
177+func (h powerHandler) SetMode(r *wlroutputpower.ZwlrOutputPowerV1, mode uint32) {
178+ if mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff) && mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn) {
179+ r.PostError(uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode), "invalid mode")
180+ return
181+ }
182+ if h.p.output == "" {
183+ return
184+ }
185+ h.server.emit(ports.OutputPower{Output: h.p.output, On: mode == uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)})
186+}
187+
188+func powerMode(on bool) uint32 {
189+ if on {
190+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)
191+ }
192+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
193+}
194+
195+// setOutputsOff applies core's power state: each object of an output
196+// whose mode changed hears it; objects of gone outputs fail.
197+func (s *Server) setOutputsOff(off map[string]bool) {
198+ names := map[string]bool{}
199+ for _, o := range s.outputs {
200+ names[o.name()] = true
201+ }
202+ kept := s.powers[:0]
203+ for _, p := range s.powers {
204+ if !names[p.output] {
205+ // Failed objects are inert, even if an output of that name
206+ // comes back.
207+ p.res.SendFailed()
208+ p.res.OnDestroy = nil
209+ p.output = ""
210+ continue
211+ }
212+ if off[p.output] != s.outputsOff[p.output] {
213+ p.res.SendMode(powerMode(!off[p.output]))
214+ }
215+ kept = append(kept, p)
216+ }
217+ s.powers = kept
218+ s.outputsOff = off
219+}
@@ -0,0 +1,219 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/extidlenotify"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/wlroutputpower"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// Idle and output power. ext_idle_notifier_v1: each notification has its
14+// own timeout, restarted by user activity (ports.UserActivity from core);
15+// idle inhibitors hold every notification but input-idle ones.
16+// zwlr_output_power_v1: a client such as wlopm turns a display off or on;
17+// core decides and the scene carries it.
18+
19+// idleNotification is one ext_idle_notification_v1.
20+type idleNotification struct {
21+ res *extidlenotify.ExtIdleNotificationV1
22+ timeout time.Duration
23+ input bool // get_input_idle_notification: ignores idle inhibitors
24+ idle bool // idled sent, resumed not yet
25+ timer *time.Timer
26+ gen uint64 // bumped on stop: a firing timer of an older gen is stale
27+}
28+
29+// outputPower is one zwlr_output_power_v1.
30+type outputPower struct {
31+ res *wlroutputpower.ZwlrOutputPowerV1
32+ output string
33+}
34+
35+func registerIdle(d *server.Display, s *Server) error {
36+ if err := extidlenotify.NewExtIdleNotifierV1Global(d, 2, func(c server.Client, v, id uint32) {
37+ _, _ = extidlenotify.NewExtIdleNotifierV1(c, int32(v), id, idleNotifier{s})
38+ }); err != nil {
39+ return err
40+ }
41+ return wlroutputpower.NewZwlrOutputPowerManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
42+ _, _ = wlroutputpower.NewZwlrOutputPowerManagerV1(c, int32(v), id, powerManager{s})
43+ })
44+}
45+
46+type idleNotifier struct{ server *Server }
47+
48+func (idleNotifier) Destroy(*extidlenotify.ExtIdleNotifierV1) {}
49+
50+func (n idleNotifier) GetIdleNotification(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, _ *wayland.Seat) {
51+ n.server.addIdle(r, id, timeout, false)
52+}
53+
54+func (n idleNotifier) GetInputIdleNotification(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, _ *wayland.Seat) {
55+ n.server.addIdle(r, id, timeout, true)
56+}
57+
58+type idleNotificationHandler struct{}
59+
60+func (idleNotificationHandler) Destroy(*extidlenotify.ExtIdleNotificationV1) {}
61+
62+func (s *Server) addIdle(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, input bool) {
63+ res, err := extidlenotify.NewExtIdleNotificationV1(r.Client(), r.Version(), id, idleNotificationHandler{})
64+ if err != nil {
65+ return
66+ }
67+ n := &idleNotification{res: res, timeout: time.Duration(timeout) * time.Millisecond, input: input}
68+ s.idleNotes = append(s.idleNotes, n)
69+ res.OnDestroy = func() {
70+ n.stop()
71+ for i, x := range s.idleNotes {
72+ if x == n {
73+ s.idleNotes = append(s.idleNotes[:i], s.idleNotes[i+1:]...)
74+ break
75+ }
76+ }
77+ }
78+ s.armIdle(n)
79+}
80+
81+// idleHeld reports whether an idle inhibitor keeps n from firing.
82+func (s *Server) idleHeld(n *idleNotification) bool {
83+ return !n.input && len(s.idleWindows) > 0
84+}
85+
86+// armIdle restarts n's timer, unless an inhibitor holds it. The timer
87+// fires on the display goroutine.
88+func (s *Server) armIdle(n *idleNotification) {
89+ n.stop()
90+ if s.idleHeld(n) {
91+ return
92+ }
93+ gen := n.gen
94+ // Core reports activity at most once per ActivityInterval: a shorter
95+ // timeout would fire while the user is still typing.
96+ n.timer = time.AfterFunc(max(n.timeout, ports.ActivityInterval), func() {
97+ s.display.Do(func() {
98+ // A timer stopped or replaced while it waited for Do is stale.
99+ if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
100+ return
101+ }
102+ n.idle = true
103+ n.res.SendIdled()
104+ })
105+ })
106+}
107+
108+func (n *idleNotification) stop() {
109+ n.gen++
110+ if n.timer != nil {
111+ n.timer.Stop()
112+ n.timer = nil
113+ }
114+}
115+
116+// userActivity resumes idle notifications and restarts their timers.
117+func (s *Server) userActivity() {
118+ for _, n := range s.idleNotes {
119+ if n.idle {
120+ n.idle = false
121+ n.res.SendResumed()
122+ }
123+ s.armIdle(n)
124+ }
125+}
126+
127+// syncIdle follows idle inhibitors: while one exists, inhibitable timers
128+// stop; once the last one goes, they start again from zero.
129+func (s *Server) syncIdle(wasHeld bool) {
130+ if held := len(s.idleWindows) > 0; held != wasHeld {
131+ for _, n := range s.idleNotes {
132+ if !n.input {
133+ s.armIdle(n)
134+ }
135+ }
136+ }
137+}
138+
139+type powerManager struct{ server *Server }
140+
141+func (powerManager) Destroy(*wlroutputpower.ZwlrOutputPowerManagerV1) {}
142+
143+func (m powerManager) GetOutputPower(r *wlroutputpower.ZwlrOutputPowerManagerV1, id uint32, wl *wayland.Output) {
144+ s := m.server
145+ o := s.outputOf(wl)
146+ p := &outputPower{}
147+ res, err := wlroutputpower.NewZwlrOutputPowerV1(r.Client(), r.Version(), id, powerHandler{s, p})
148+ if err != nil {
149+ return
150+ }
151+ p.res = res
152+ if o == nil {
153+ // Unplugged or unknown: the object is inert.
154+ res.SendFailed()
155+ return
156+ }
157+ p.output = o.name()
158+ s.powers = append(s.powers, p)
159+ res.OnDestroy = func() {
160+ for i, x := range s.powers {
161+ if x == p {
162+ s.powers = append(s.powers[:i], s.powers[i+1:]...)
163+ break
164+ }
165+ }
166+ }
167+ res.SendMode(powerMode(!s.outputsOff[p.output]))
168+}
169+
170+type powerHandler struct {
171+ server *Server
172+ p *outputPower
173+}
174+
175+func (powerHandler) Destroy(*wlroutputpower.ZwlrOutputPowerV1) {}
176+
177+func (h powerHandler) SetMode(r *wlroutputpower.ZwlrOutputPowerV1, mode uint32) {
178+ if mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff) && mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn) {
179+ r.PostError(uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode), "invalid mode")
180+ return
181+ }
182+ if h.p.output == "" {
183+ return
184+ }
185+ h.server.emit(ports.OutputPower{Output: h.p.output, On: mode == uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)})
186+}
187+
188+func powerMode(on bool) uint32 {
189+ if on {
190+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)
191+ }
192+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
193+}
194+
195+// setOutputsOff applies core's power state: each object of an output
196+// whose mode changed hears it; objects of gone outputs fail.
197+func (s *Server) setOutputsOff(off map[string]bool) {
198+ names := map[string]bool{}
199+ for _, o := range s.outputs {
200+ names[o.name()] = true
201+ }
202+ kept := s.powers[:0]
203+ for _, p := range s.powers {
204+ if !names[p.output] {
205+ // Failed objects are inert, even if an output of that name
206+ // comes back.
207+ p.res.SendFailed()
208+ p.res.OnDestroy = nil
209+ p.output = ""
210+ continue
211+ }
212+ if off[p.output] != s.outputsOff[p.output] {
213+ p.res.SendMode(powerMode(!off[p.output]))
214+ }
215+ kept = append(kept, p)
216+ }
217+ s.powers = kept
218+ s.outputsOff = off
219+}
A internal/adapters/wayland/idle_test.go +208 -0
@@ -0,0 +1,208 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/extidlenotify"
9+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
10+ "github.com/bnema/purego-libwayland/protocol/wlroutputpower"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+// eventProxy records the opcodes, and first uint argument, of its events.
15+type eventProxy struct {
16+ wlturbo.BaseProxy
17+ events chan [2]uint32
18+}
19+
20+func (p *eventProxy) Dispatch(e *wlturbo.Event) {
21+ var arg uint32
22+ if len(e.Data()) >= 4 {
23+ arg = e.Uint32()
24+ }
25+ p.events <- [2]uint32{uint32(e.Opcode), arg}
26+}
27+
28+func newEventProxy(c *wlturbo.Display) (uint32, *eventProxy) {
29+ id := c.AllocateID()
30+ p := &eventProxy{events: make(chan [2]uint32, 16)}
31+ p.SetID(id)
32+ c.Context().Register(p)
33+ return id, p
34+}
35+
36+// next dispatches until p gets an event, or fails after wait.
37+func (p *eventProxy) next(t *testing.T, c *wlturbo.Display, wait time.Duration) [2]uint32 {
38+ t.Helper()
39+ deadline := time.Now().Add(wait)
40+ for time.Now().Before(deadline) {
41+ if err := c.Roundtrip(); err != nil {
42+ t.Fatal(err)
43+ }
44+ select {
45+ case e := <-p.events:
46+ return e
47+ case <-time.After(5 * time.Millisecond):
48+ }
49+ }
50+ t.Fatal("no event")
51+ return [2]uint32{}
52+}
53+
54+// none checks p gets no event for d.
55+func (p *eventProxy) none(t *testing.T, c *wlturbo.Display, d time.Duration) {
56+ t.Helper()
57+ deadline := time.Now().Add(d)
58+ for time.Now().Before(deadline) {
59+ if err := c.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ select {
63+ case e := <-p.events:
64+ t.Fatalf("unexpected event %v", e)
65+ case <-time.After(5 * time.Millisecond):
66+ }
67+ }
68+}
69+
70+// A notification idles after its timeout, resumes on user activity and
71+// idles again; an idle inhibitor holds it, an input-idle one ignores it.
72+func TestIdleNotification(t *testing.T) {
73+ s, events, commands, _, dir := contentServer(t)
74+ c := protocolClient(t, s, dir)
75+ seat := bindProtocol(t, c, "wl_seat")
76+ registerProtocol(t, c, seat)
77+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
78+ registerProtocol(t, c, notifier)
79+ id, n := newEventProxy(c)
80+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(50), seat)
81+ idled, resumed := uint32(extidlenotify.ExtIdleNotificationV1EventIdled), uint32(extidlenotify.ExtIdleNotificationV1EventResumed)
82+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
83+ t.Fatalf("event %v, want idled", e)
84+ }
85+ commands <- ports.UserActivity{}
86+ if e := n.next(t, c, 2*time.Second); e[0] != resumed {
87+ t.Fatalf("event %v, want resumed", e)
88+ }
89+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
90+ t.Fatalf("event %v, want idled again", e)
91+ }
92+ commands <- ports.UserActivity{}
93+ n.next(t, c, 2*time.Second) // resumed
94+
95+ // An idle inhibitor holds it; input-idle notifications still fire.
96+ _, surf, _ := surfaceMapper(t, c, events)()
97+ inhibitMgr := bindProtocol(t, c, "zwp_idle_inhibit_manager_v1")
98+ in := c.AllocateID()
99+ registerProtocol(t, c, in)
100+ requestProtocol(t, c, inhibitMgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, in, surf)
101+ clientEvent[ports.IdleInhibit](t, events)
102+ inputID, input := newEventProxy(c)
103+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetInputIdleNotification, inputID, uint32(50), seat)
104+ if e := input.next(t, c, 2*time.Second); e[0] != idled {
105+ t.Fatalf("input-idle event %v, want idled", e)
106+ }
107+ n.none(t, c, 200*time.Millisecond)
108+ // The inhibitor goes: the held notification counts from zero.
109+ requestProtocol(t, c, in, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
110+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
111+ t.Fatalf("event %v, want idled after the inhibitor", e)
112+ }
113+ requestProtocol(t, c, id, extidlenotify.ExtIdleNotificationV1RequestDestroy)
114+ requestProtocol(t, c, inputID, extidlenotify.ExtIdleNotificationV1RequestDestroy)
115+ if err := c.Roundtrip(); err != nil {
116+ t.Fatal(err)
117+ }
118+}
119+
120+// An output power object reports the mode, turns set_mode into a core
121+// event, hears core's decision, and fails when its output goes.
122+func TestOutputPower(t *testing.T) {
123+ s, events, commands, _, dir := contentServer(t)
124+ c := protocolClient(t, s, dir)
125+ output := bindProtocol(t, c, "wl_output")
126+ registerProtocol(t, c, output)
127+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
128+ registerProtocol(t, c, mgr)
129+ id, p := newEventProxy(c)
130+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
131+ modeEv, failed := uint32(wlroutputpower.ZwlrOutputPowerV1EventMode), uint32(wlroutputpower.ZwlrOutputPowerV1EventFailed)
132+ on, off := uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn), uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
133+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, on} {
134+ t.Fatalf("initial %v", e)
135+ }
136+ name := testOutputs[0].Info.Name
137+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
138+ if v := clientEvent[ports.OutputPower](t, events); v != (ports.OutputPower{Output: name, On: false}) {
139+ t.Fatal(v)
140+ }
141+ commands <- ports.SetOutputs{Outputs: testOutputs, Off: []string{name}}
142+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, off} {
143+ t.Fatalf("after off %v", e)
144+ }
145+ // Unchanged state: no event.
146+ commands <- ports.SetOutputs{Outputs: testOutputs, Off: []string{name}}
147+ p.none(t, c, 50*time.Millisecond)
148+ commands <- ports.SetOutputs{Outputs: testOutputs}
149+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, on} {
150+ t.Fatalf("after on %v", e)
151+ }
152+ // The output goes: the object fails.
153+ commands <- ports.SetOutputs{}
154+ if e := p.next(t, c, 2*time.Second); e[0] != failed {
155+ t.Fatalf("after unplug %v", e)
156+ }
157+ // Back with the same name: the failed object controls nothing.
158+ commands <- ports.SetOutputs{Outputs: testOutputs}
159+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ select {
164+ case ev := <-events:
165+ if v, ok := ev.(ports.OutputPower); ok {
166+ t.Fatalf("failed object sent %+v", v)
167+ }
168+ case <-time.After(50 * time.Millisecond):
169+ }
170+}
171+
172+// A timeout shorter than the activity interval still waits for it, so
173+// continuous input keeps the notification from idling.
174+func TestIdleShortTimeout(t *testing.T) {
175+ s, _, commands, _, dir := contentServer(t)
176+ c := protocolClient(t, s, dir)
177+ seat := bindProtocol(t, c, "wl_seat")
178+ registerProtocol(t, c, seat)
179+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
180+ id, n := newEventProxy(c)
181+ start := time.Now()
182+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(0), seat)
183+ n.next(t, c, 2*time.Second)
184+ if d := time.Since(start); d < ports.ActivityInterval {
185+ t.Fatalf("idled after %v, before the activity interval", d)
186+ }
187+ commands <- ports.UserActivity{}
188+ n.next(t, c, 2*time.Second) // resumed
189+ for range 4 {
190+ time.Sleep(ports.ActivityInterval / 2)
191+ commands <- ports.UserActivity{}
192+ }
193+ n.none(t, c, ports.ActivityInterval/2)
194+}
195+
196+// An unknown mode is a protocol error.
197+func TestOutputPowerInvalidMode(t *testing.T) {
198+ s, _, _, _, dir := contentServer(t)
199+ c := protocolClient(t, s, dir)
200+ output := bindProtocol(t, c, "wl_output")
201+ registerProtocol(t, c, output)
202+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
203+ id := c.AllocateID()
204+ registerProtocol(t, c, id)
205+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
206+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, uint32(7))
207+ expectProtocolError(t, c, id, uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode))
208+}
@@ -0,0 +1,208 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/extidlenotify"
9+ "github.com/bnema/purego-libwayland/protocol/idleinhibit"
10+ "github.com/bnema/purego-libwayland/protocol/wlroutputpower"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+// eventProxy records the opcodes, and first uint argument, of its events.
15+type eventProxy struct {
16+ wlturbo.BaseProxy
17+ events chan [2]uint32
18+}
19+
20+func (p *eventProxy) Dispatch(e *wlturbo.Event) {
21+ var arg uint32
22+ if len(e.Data()) >= 4 {
23+ arg = e.Uint32()
24+ }
25+ p.events <- [2]uint32{uint32(e.Opcode), arg}
26+}
27+
28+func newEventProxy(c *wlturbo.Display) (uint32, *eventProxy) {
29+ id := c.AllocateID()
30+ p := &eventProxy{events: make(chan [2]uint32, 16)}
31+ p.SetID(id)
32+ c.Context().Register(p)
33+ return id, p
34+}
35+
36+// next dispatches until p gets an event, or fails after wait.
37+func (p *eventProxy) next(t *testing.T, c *wlturbo.Display, wait time.Duration) [2]uint32 {
38+ t.Helper()
39+ deadline := time.Now().Add(wait)
40+ for time.Now().Before(deadline) {
41+ if err := c.Roundtrip(); err != nil {
42+ t.Fatal(err)
43+ }
44+ select {
45+ case e := <-p.events:
46+ return e
47+ case <-time.After(5 * time.Millisecond):
48+ }
49+ }
50+ t.Fatal("no event")
51+ return [2]uint32{}
52+}
53+
54+// none checks p gets no event for d.
55+func (p *eventProxy) none(t *testing.T, c *wlturbo.Display, d time.Duration) {
56+ t.Helper()
57+ deadline := time.Now().Add(d)
58+ for time.Now().Before(deadline) {
59+ if err := c.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ select {
63+ case e := <-p.events:
64+ t.Fatalf("unexpected event %v", e)
65+ case <-time.After(5 * time.Millisecond):
66+ }
67+ }
68+}
69+
70+// A notification idles after its timeout, resumes on user activity and
71+// idles again; an idle inhibitor holds it, an input-idle one ignores it.
72+func TestIdleNotification(t *testing.T) {
73+ s, events, commands, _, dir := contentServer(t)
74+ c := protocolClient(t, s, dir)
75+ seat := bindProtocol(t, c, "wl_seat")
76+ registerProtocol(t, c, seat)
77+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
78+ registerProtocol(t, c, notifier)
79+ id, n := newEventProxy(c)
80+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(50), seat)
81+ idled, resumed := uint32(extidlenotify.ExtIdleNotificationV1EventIdled), uint32(extidlenotify.ExtIdleNotificationV1EventResumed)
82+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
83+ t.Fatalf("event %v, want idled", e)
84+ }
85+ commands <- ports.UserActivity{}
86+ if e := n.next(t, c, 2*time.Second); e[0] != resumed {
87+ t.Fatalf("event %v, want resumed", e)
88+ }
89+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
90+ t.Fatalf("event %v, want idled again", e)
91+ }
92+ commands <- ports.UserActivity{}
93+ n.next(t, c, 2*time.Second) // resumed
94+
95+ // An idle inhibitor holds it; input-idle notifications still fire.
96+ _, surf, _ := surfaceMapper(t, c, events)()
97+ inhibitMgr := bindProtocol(t, c, "zwp_idle_inhibit_manager_v1")
98+ in := c.AllocateID()
99+ registerProtocol(t, c, in)
100+ requestProtocol(t, c, inhibitMgr, idleinhibit.ZwpIdleInhibitManagerV1RequestCreateInhibitor, in, surf)
101+ clientEvent[ports.IdleInhibit](t, events)
102+ inputID, input := newEventProxy(c)
103+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetInputIdleNotification, inputID, uint32(50), seat)
104+ if e := input.next(t, c, 2*time.Second); e[0] != idled {
105+ t.Fatalf("input-idle event %v, want idled", e)
106+ }
107+ n.none(t, c, 200*time.Millisecond)
108+ // The inhibitor goes: the held notification counts from zero.
109+ requestProtocol(t, c, in, idleinhibit.ZwpIdleInhibitorV1RequestDestroy)
110+ if e := n.next(t, c, 2*time.Second); e[0] != idled {
111+ t.Fatalf("event %v, want idled after the inhibitor", e)
112+ }
113+ requestProtocol(t, c, id, extidlenotify.ExtIdleNotificationV1RequestDestroy)
114+ requestProtocol(t, c, inputID, extidlenotify.ExtIdleNotificationV1RequestDestroy)
115+ if err := c.Roundtrip(); err != nil {
116+ t.Fatal(err)
117+ }
118+}
119+
120+// An output power object reports the mode, turns set_mode into a core
121+// event, hears core's decision, and fails when its output goes.
122+func TestOutputPower(t *testing.T) {
123+ s, events, commands, _, dir := contentServer(t)
124+ c := protocolClient(t, s, dir)
125+ output := bindProtocol(t, c, "wl_output")
126+ registerProtocol(t, c, output)
127+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
128+ registerProtocol(t, c, mgr)
129+ id, p := newEventProxy(c)
130+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
131+ modeEv, failed := uint32(wlroutputpower.ZwlrOutputPowerV1EventMode), uint32(wlroutputpower.ZwlrOutputPowerV1EventFailed)
132+ on, off := uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn), uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
133+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, on} {
134+ t.Fatalf("initial %v", e)
135+ }
136+ name := testOutputs[0].Info.Name
137+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
138+ if v := clientEvent[ports.OutputPower](t, events); v != (ports.OutputPower{Output: name, On: false}) {
139+ t.Fatal(v)
140+ }
141+ commands <- ports.SetOutputs{Outputs: testOutputs, Off: []string{name}}
142+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, off} {
143+ t.Fatalf("after off %v", e)
144+ }
145+ // Unchanged state: no event.
146+ commands <- ports.SetOutputs{Outputs: testOutputs, Off: []string{name}}
147+ p.none(t, c, 50*time.Millisecond)
148+ commands <- ports.SetOutputs{Outputs: testOutputs}
149+ if e := p.next(t, c, 2*time.Second); e != [2]uint32{modeEv, on} {
150+ t.Fatalf("after on %v", e)
151+ }
152+ // The output goes: the object fails.
153+ commands <- ports.SetOutputs{}
154+ if e := p.next(t, c, 2*time.Second); e[0] != failed {
155+ t.Fatalf("after unplug %v", e)
156+ }
157+ // Back with the same name: the failed object controls nothing.
158+ commands <- ports.SetOutputs{Outputs: testOutputs}
159+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, off)
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ select {
164+ case ev := <-events:
165+ if v, ok := ev.(ports.OutputPower); ok {
166+ t.Fatalf("failed object sent %+v", v)
167+ }
168+ case <-time.After(50 * time.Millisecond):
169+ }
170+}
171+
172+// A timeout shorter than the activity interval still waits for it, so
173+// continuous input keeps the notification from idling.
174+func TestIdleShortTimeout(t *testing.T) {
175+ s, _, commands, _, dir := contentServer(t)
176+ c := protocolClient(t, s, dir)
177+ seat := bindProtocol(t, c, "wl_seat")
178+ registerProtocol(t, c, seat)
179+ notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2)
180+ id, n := newEventProxy(c)
181+ start := time.Now()
182+ requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(0), seat)
183+ n.next(t, c, 2*time.Second)
184+ if d := time.Since(start); d < ports.ActivityInterval {
185+ t.Fatalf("idled after %v, before the activity interval", d)
186+ }
187+ commands <- ports.UserActivity{}
188+ n.next(t, c, 2*time.Second) // resumed
189+ for range 4 {
190+ time.Sleep(ports.ActivityInterval / 2)
191+ commands <- ports.UserActivity{}
192+ }
193+ n.none(t, c, ports.ActivityInterval/2)
194+}
195+
196+// An unknown mode is a protocol error.
197+func TestOutputPowerInvalidMode(t *testing.T) {
198+ s, _, _, _, dir := contentServer(t)
199+ c := protocolClient(t, s, dir)
200+ output := bindProtocol(t, c, "wl_output")
201+ registerProtocol(t, c, output)
202+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
203+ id := c.AllocateID()
204+ registerProtocol(t, c, id)
205+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
206+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, uint32(7))
207+ expectProtocolError(t, c, id, uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode))
208+}
M internal/adapters/wayland/inhibit.go +2 -0
@@ -127,7 +127,9 @@ func (s *Server) syncInhibitors() {
127127 for id, on := range diffSets(s.idleWindows, idle) {
128128 s.emit(ports.IdleInhibit{Window: id, Active: on})
129129 }
130+ wasHeld := len(s.idleWindows) > 0
130131 s.shortcutWindows, s.idleWindows = shortcuts, idle
132+ s.syncIdle(wasHeld)
131133 }
132134
133135 // diffSets is the membership changes from old to cur.
@@ -127,7 +127,9 @@ func (s *Server) syncInhibitors() {
127 for id, on := range diffSets(s.idleWindows, idle) {127 for id, on := range diffSets(s.idleWindows, idle) {
128 s.emit(ports.IdleInhibit{Window: id, Active: on})128 s.emit(ports.IdleInhibit{Window: id, Active: on})
129 }129 }
130+ wasHeld := len(s.idleWindows) > 0
130 s.shortcutWindows, s.idleWindows = shortcuts, idle131 s.shortcutWindows, s.idleWindows = shortcuts, idle
132+ s.syncIdle(wasHeld)
131 }133 }
132 134
133 // diffSets is the membership changes from old to cur.135 // diffSets is the membership changes from old to cur.
M internal/adapters/wayland/server.go +17 -5
@@ -130,11 +130,16 @@ type Server struct {
130130 // shortcutWindows and idleWindows what core was told.
131131 inhibitors []*inhibitor
132132 shortcutWindows, idleWindows map[ports.WindowID]bool
133- contentNotify chan struct{}
134- contentReady chan struct{}
135- outputs []*output
136- focusedOutput string
137- fractions map[*surface]*fractionalscale.WpFractionalScaleV1
133+ // idleNotes and powers are the idle notifications and output power
134+ // objects (idle.go); outputsOff the outputs core turned off.
135+ idleNotes []*idleNotification
136+ powers []*outputPower
137+ outputsOff map[string]bool
138+ contentNotify chan struct{}
139+ contentReady chan struct{}
140+ outputs []*output
141+ focusedOutput string
142+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
138143 // cursorSurface is the wl_pointer.set_cursor surface in use.
139144 cursorSurface *surface
140145 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -588,6 +593,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
588593 }
589594 case ports.SetOutputs:
590595 s.setOutputs(c)
596+ off := map[string]bool{}
597+ for _, name := range c.Off {
598+ off[name] = true
599+ }
600+ s.setOutputsOff(off)
601+ case ports.UserActivity:
602+ s.userActivity()
591603 case ports.SlotsPending:
592604 s.slotsPending = c.Pending
593605 case ports.ShortcutsInhibitState:
@@ -130,11 +130,16 @@ type Server struct {
130 // shortcutWindows and idleWindows what core was told.130 // shortcutWindows and idleWindows what core was told.
131 inhibitors []*inhibitor131 inhibitors []*inhibitor
132 shortcutWindows, idleWindows map[ports.WindowID]bool132 shortcutWindows, idleWindows map[ports.WindowID]bool
133- contentNotify chan struct{}133+ // idleNotes and powers are the idle notifications and output power
134- contentReady chan struct{}134+ // objects (idle.go); outputsOff the outputs core turned off.
135- outputs []*output135+ idleNotes []*idleNotification
136- focusedOutput string136+ powers []*outputPower
137- fractions map[*surface]*fractionalscale.WpFractionalScaleV1137+ outputsOff map[string]bool
138+ contentNotify chan struct{}
139+ contentReady chan struct{}
140+ outputs []*output
141+ focusedOutput string
142+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
138 // cursorSurface is the wl_pointer.set_cursor surface in use.143 // cursorSurface is the wl_pointer.set_cursor surface in use.
139 cursorSurface *surface144 cursorSurface *surface
140 // cursorMu guards only the latest cursor change for forwardCursors.145 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -588,6 +593,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
588 }593 }
589 case ports.SetOutputs:594 case ports.SetOutputs:
590 s.setOutputs(c)595 s.setOutputs(c)
596+ off := map[string]bool{}
597+ for _, name := range c.Off {
598+ off[name] = true
599+ }
600+ s.setOutputsOff(off)
601+ case ports.UserActivity:
602+ s.userActivity()
591 case ports.SlotsPending:603 case ports.SlotsPending:
592 s.slotsPending = c.Pending604 s.slotsPending = c.Pending
593 case ports.ShortcutsInhibitState:605 case ports.ShortcutsInhibitState:
M internal/core/core.go +14 -3
@@ -9,6 +9,7 @@ import (
99 "slices"
1010 "strconv"
1111 "strings"
12+ "time"
1213 "unicode"
1314 )
1415
@@ -95,6 +96,9 @@ type Core struct {
9596 inhibitors map[WindowID]bool
9697 inhibiting WindowID
9798 idle map[WindowID]bool
99+ // activity is when core last told wayland about user input
100+ // (ports.UserActivity), sent at most once per ActivityInterval.
101+ activity time.Time
98102 }
99103
100104 func keyName(s string) string {
@@ -370,7 +374,7 @@ func (c *Core) publish(ctx context.Context) error {
370374 return err
371375 }
372376 // Clients learn outputs and scales before the configures sized for them.
373- if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) {
377+ if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}); !sameOutputs(v, c.sentOutputs) {
374378 if err := c.command(ctx, v); err != nil {
375379 return err
376380 }
@@ -398,7 +402,7 @@ func (c *Core) publish(ctx context.Context) error {
398402 for i, sc := range c.screens {
399403 c.seq++
400404 o := sc.mon.Output()
401- scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)}
405+ scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)}
402406 layout := sc.mon.Layout()
403407 // Only the focused output lights the focused window's lines.
404408 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
@@ -541,7 +545,7 @@ func latest[T any](ch chan T, v T) {
541545 }
542546
543547 func sameOutputs(a, b ports.SetOutputs) bool {
544- return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs)
548+ return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Off, b.Off)
545549 }
546550
547551 // allLayers lists the layer surfaces of every output.
@@ -658,6 +662,10 @@ func (c *Core) Run(ctx context.Context) error {
658662 return nil
659663 }
660664 continue
665+ case ports.OutputPower:
666+ if i := c.screenIndex(v.Output); i >= 0 && c.screens[i].off == v.On {
667+ c.screens[i].off = !v.On
668+ }
661669 case ports.IdleInhibit:
662670 if v.Active {
663671 c.idle[v.Window] = true
@@ -751,6 +759,9 @@ func (c *Core) Run(ctx context.Context) error {
751759 c.ch.Input = nil
752760 continue
753761 }
762+ if err := c.userActivity(ctx); err != nil {
763+ return nil
764+ }
754765 switch v := ev.(type) {
755766 case ports.PointerMotion:
756767 // A locked pointer stays still; relative motion still flows.
@@ -9,6 +9,7 @@ import (
9 "slices"9 "slices"
10 "strconv"10 "strconv"
11 "strings"11 "strings"
12+ "time"
12 "unicode"13 "unicode"
13 )14 )
14 15
@@ -95,6 +96,9 @@ type Core struct {
95 inhibitors map[WindowID]bool96 inhibitors map[WindowID]bool
96 inhibiting WindowID97 inhibiting WindowID
97 idle map[WindowID]bool98 idle map[WindowID]bool
99+ // activity is when core last told wayland about user input
100+ // (ports.UserActivity), sent at most once per ActivityInterval.
101+ activity time.Time
98 }102 }
99 103
100 func keyName(s string) string {104 func keyName(s string) string {
@@ -370,7 +374,7 @@ func (c *Core) publish(ctx context.Context) error {
370 return err374 return err
371 }375 }
372 // Clients learn outputs and scales before the configures sized for them.376 // Clients learn outputs and scales before the configures sized for them.
373- if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) {377+ if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}); !sameOutputs(v, c.sentOutputs) {
374 if err := c.command(ctx, v); err != nil {378 if err := c.command(ctx, v); err != nil {
375 return err379 return err
376 }380 }
@@ -398,7 +402,7 @@ func (c *Core) publish(ctx context.Context) error {
398 for i, sc := range c.screens {402 for i, sc := range c.screens {
399 c.seq++403 c.seq++
400 o := sc.mon.Output()404 o := sc.mon.Output()
401- scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)}405+ scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)}
402 layout := sc.mon.Layout()406 layout := sc.mon.Layout()
403 // Only the focused output lights the focused window's lines.407 // Only the focused output lights the focused window's lines.
404 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)408 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
@@ -541,7 +545,7 @@ func latest[T any](ch chan T, v T) {
541 }545 }
542 546
543 func sameOutputs(a, b ports.SetOutputs) bool {547 func sameOutputs(a, b ports.SetOutputs) bool {
544- return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs)548+ return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Off, b.Off)
545 }549 }
546 550
547 // allLayers lists the layer surfaces of every output.551 // allLayers lists the layer surfaces of every output.
@@ -658,6 +662,10 @@ func (c *Core) Run(ctx context.Context) error {
658 return nil662 return nil
659 }663 }
660 continue664 continue
665+ case ports.OutputPower:
666+ if i := c.screenIndex(v.Output); i >= 0 && c.screens[i].off == v.On {
667+ c.screens[i].off = !v.On
668+ }
661 case ports.IdleInhibit:669 case ports.IdleInhibit:
662 if v.Active {670 if v.Active {
663 c.idle[v.Window] = true671 c.idle[v.Window] = true
@@ -751,6 +759,9 @@ func (c *Core) Run(ctx context.Context) error {
751 c.ch.Input = nil759 c.ch.Input = nil
752 continue760 continue
753 }761 }
762+ if err := c.userActivity(ctx); err != nil {
763+ return nil
764+ }
754 switch v := ev.(type) {765 switch v := ev.(type) {
755 case ports.PointerMotion:766 case ports.PointerMotion:
756 // A locked pointer stays still; relative motion still flows.767 // A locked pointer stays still; relative motion still flows.
M internal/core/core_test.go +6 -5
@@ -28,14 +28,15 @@ func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
2828 return receive(t, ch)[0]
2929 }
3030
31-// command receives the next client command, skipping output scale updates.
31+// command receives the next client command, skipping output scale updates
32+// and user activity reports.
3233 func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
3334 t.Helper()
3435 for {
35- if v := receive(t, ch); v != nil {
36- if _, ok := v.(ports.SetOutputs); !ok {
37- return v
38- }
36+ switch v := receive(t, ch); v.(type) {
37+ case nil, ports.SetOutputs, ports.UserActivity:
38+ default:
39+ return v
3940 }
4041 }
4142 }
@@ -28,14 +28,15 @@ func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
28 return receive(t, ch)[0]28 return receive(t, ch)[0]
29 }29 }
30 30
31-// command receives the next client command, skipping output scale updates.31+// command receives the next client command, skipping output scale updates
32+// and user activity reports.
32 func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {33 func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
33 t.Helper()34 t.Helper()
34 for {35 for {
35- if v := receive(t, ch); v != nil {36+ switch v := receive(t, ch); v.(type) {
36- if _, ok := v.(ports.SetOutputs); !ok {37+ case nil, ports.SetOutputs, ports.UserActivity:
37- return v38+ default:
38- }39+ return v
39 }40 }
40 }41 }
41 }42 }
A internal/core/power.go +30 -0
@@ -0,0 +1,30 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// userActivity tells wayland the user touched an input device, at most
11+// once per ports.ActivityInterval: idle notification timers restart.
12+func (c *Core) userActivity(ctx context.Context) error {
13+ now := time.Now()
14+ if now.Sub(c.activity) < ports.ActivityInterval {
15+ return nil
16+ }
17+ c.activity = now
18+ return c.command(ctx, ports.UserActivity{})
19+}
20+
21+// offOutputs lists the outputs turned off by a client, in screen order.
22+func (c *Core) offOutputs() []string {
23+ var off []string
24+ for _, s := range c.screens {
25+ if s.off && s.name() != "" {
26+ off = append(off, s.name())
27+ }
28+ }
29+ return off
30+}
@@ -0,0 +1,30 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// userActivity tells wayland the user touched an input device, at most
11+// once per ports.ActivityInterval: idle notification timers restart.
12+func (c *Core) userActivity(ctx context.Context) error {
13+ now := time.Now()
14+ if now.Sub(c.activity) < ports.ActivityInterval {
15+ return nil
16+ }
17+ c.activity = now
18+ return c.command(ctx, ports.UserActivity{})
19+}
20+
21+// offOutputs lists the outputs turned off by a client, in screen order.
22+func (c *Core) offOutputs() []string {
23+ var off []string
24+ for _, s := range c.screens {
25+ if s.off && s.name() != "" {
26+ off = append(off, s.name())
27+ }
28+ }
29+ return off
30+}
A internal/core/power_test.go +98 -0
@@ -0,0 +1,98 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func powerCore(t *testing.T) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
15+ t.Helper()
16+ input := make(chan ports.InputEvent, 4)
17+ client := make(chan ports.ClientEvent, 4)
18+ output := make(chan ports.OutputEvent, 2)
19+ commands := make(chan ports.ClientCommand, 64)
20+ scenes := make(chan []ports.Scene, 1)
21+ c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- c.Run(ctx) }()
28+ t.Cleanup(func() { cancel(); receive(t, done) })
29+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
30+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
31+ return input, client, output, commands, scenes
32+}
33+
34+// sceneOf waits for a scene set in which pred holds for the named output.
35+func sceneOf(t *testing.T, scenes <-chan []ports.Scene, name string, pred func(ports.Scene) bool) {
36+ t.Helper()
37+ deadline := time.After(2 * time.Second)
38+ for {
39+ select {
40+ case set := <-scenes:
41+ for _, s := range set {
42+ if s.Output == name && pred(s) {
43+ return
44+ }
45+ }
46+ case <-deadline:
47+ t.Fatalf("no matching scene for %s", name)
48+ }
49+ }
50+}
51+
52+// A client turning an output off marks its scene and tells wayland; back
53+// on clears both. Other outputs stay on.
54+func TestOutputPower(t *testing.T) {
55+ _, client, _, commands, scenes := powerCore(t)
56+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
57+ client <- ports.OutputPower{Output: "OUT-2", On: false}
58+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
59+ for {
60+ if v := commandOf[ports.SetOutputs](t, commands); slices.Equal(v.Off, []string{"OUT-2"}) {
61+ break
62+ }
63+ }
64+ client <- ports.OutputPower{Output: "OUT-2", On: true}
65+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
66+ for {
67+ if v := commandOf[ports.SetOutputs](t, commands); len(v.Off) == 0 {
68+ break
69+ }
70+ }
71+ // Unknown outputs are ignored.
72+ client <- ports.OutputPower{Output: "NOPE", On: false}
73+ sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off })
74+}
75+
76+// Input reports user activity to wayland, at most once per interval.
77+func TestUserActivity(t *testing.T) {
78+ input, _, _, commands, _ := powerCore(t)
79+ for range 3 {
80+ input <- ports.PointerMotion{X: 1, Y: 1}
81+ }
82+ commandOf[ports.UserActivity](t, commands)
83+ deadline := time.After(ports.ActivityInterval / 2)
84+ for {
85+ select {
86+ case c := <-commands:
87+ if _, ok := c.(ports.UserActivity); ok {
88+ t.Fatal("second UserActivity within the interval")
89+ }
90+ continue
91+ case <-deadline:
92+ }
93+ break
94+ }
95+ time.Sleep(ports.ActivityInterval)
96+ input <- ports.PointerMotion{X: 2, Y: 2}
97+ commandOf[ports.UserActivity](t, commands)
98+}
@@ -0,0 +1,98 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func powerCore(t *testing.T) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
15+ t.Helper()
16+ input := make(chan ports.InputEvent, 4)
17+ client := make(chan ports.ClientEvent, 4)
18+ output := make(chan ports.OutputEvent, 2)
19+ commands := make(chan ports.ClientCommand, 64)
20+ scenes := make(chan []ports.Scene, 1)
21+ c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- c.Run(ctx) }()
28+ t.Cleanup(func() { cancel(); receive(t, done) })
29+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
30+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
31+ return input, client, output, commands, scenes
32+}
33+
34+// sceneOf waits for a scene set in which pred holds for the named output.
35+func sceneOf(t *testing.T, scenes <-chan []ports.Scene, name string, pred func(ports.Scene) bool) {
36+ t.Helper()
37+ deadline := time.After(2 * time.Second)
38+ for {
39+ select {
40+ case set := <-scenes:
41+ for _, s := range set {
42+ if s.Output == name && pred(s) {
43+ return
44+ }
45+ }
46+ case <-deadline:
47+ t.Fatalf("no matching scene for %s", name)
48+ }
49+ }
50+}
51+
52+// A client turning an output off marks its scene and tells wayland; back
53+// on clears both. Other outputs stay on.
54+func TestOutputPower(t *testing.T) {
55+ _, client, _, commands, scenes := powerCore(t)
56+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
57+ client <- ports.OutputPower{Output: "OUT-2", On: false}
58+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return s.Off })
59+ for {
60+ if v := commandOf[ports.SetOutputs](t, commands); slices.Equal(v.Off, []string{"OUT-2"}) {
61+ break
62+ }
63+ }
64+ client <- ports.OutputPower{Output: "OUT-2", On: true}
65+ sceneOf(t, scenes, "OUT-2", func(s ports.Scene) bool { return !s.Off })
66+ for {
67+ if v := commandOf[ports.SetOutputs](t, commands); len(v.Off) == 0 {
68+ break
69+ }
70+ }
71+ // Unknown outputs are ignored.
72+ client <- ports.OutputPower{Output: "NOPE", On: false}
73+ sceneOf(t, scenes, "OUT-1", func(s ports.Scene) bool { return !s.Off })
74+}
75+
76+// Input reports user activity to wayland, at most once per interval.
77+func TestUserActivity(t *testing.T) {
78+ input, _, _, commands, _ := powerCore(t)
79+ for range 3 {
80+ input <- ports.PointerMotion{X: 1, Y: 1}
81+ }
82+ commandOf[ports.UserActivity](t, commands)
83+ deadline := time.After(ports.ActivityInterval / 2)
84+ for {
85+ select {
86+ case c := <-commands:
87+ if _, ok := c.(ports.UserActivity); ok {
88+ t.Fatal("second UserActivity within the interval")
89+ }
90+ continue
91+ case <-deadline:
92+ }
93+ break
94+ }
95+ time.Sleep(ports.ActivityInterval)
96+ input <- ports.PointerMotion{X: 2, Y: 2}
97+ commandOf[ports.UserActivity](t, commands)
98+}
M internal/core/screens.go +2 -1
@@ -14,7 +14,8 @@ type screen struct {
1414 mon *Monitor
1515 scale, cfgScale float64
1616 primary bool
17- x int // logical left edge in the global layout
17+ x int // logical left edge in the global layout
18+ off bool // turned off by a client (output power management)
1819 layers []ports.LayerSurface
1920 placed []ports.SceneLayer
2021 }
@@ -14,7 +14,8 @@ type screen struct {
14 mon *Monitor14 mon *Monitor
15 scale, cfgScale float6415 scale, cfgScale float64
16 primary bool16 primary bool
17- x int // logical left edge in the global layout17+ x int // logical left edge in the global layout
18+ off bool // turned off by a client (output power management)
18 layers []ports.LayerSurface19 layers []ports.LayerSurface
19 placed []ports.SceneLayer20 placed []ports.SceneLayer
20 }21 }
M internal/ports/messages.go +29 -3
@@ -53,6 +53,15 @@ type IdleInhibit struct {
5353
5454 func (IdleInhibit) clientEvent() {}
5555
56+// OutputPower carries wayland → core a client turning a display off or
57+// back on (zwlr_output_power_v1).
58+type OutputPower struct {
59+ Output string
60+ On bool
61+}
62+
63+func (OutputPower) clientEvent() {}
64+
5665 // WindowFullscreenRequest carries wayland → core fullscreen requests.
5766 type WindowFullscreenRequest struct {
5867 ID WindowID
@@ -398,6 +407,17 @@ type ShortcutsInhibitState struct {
398407
399408 func (ShortcutsInhibitState) clientCommand() {}
400409
410+// UserActivity carries core → wayland that the user touched an input
411+// device, for idle notifications (ext_idle_notifier_v1). Core sends at
412+// most one per ActivityInterval.
413+type UserActivity struct{}
414+
415+func (UserActivity) clientCommand() {}
416+
417+// ActivityInterval is the most often core reports UserActivity: idle
418+// timers may start up to this much early, so no idle timeout is shorter.
419+const ActivityInterval = 100 * time.Millisecond
420+
401421 // ConfigureWindow carries core → wayland geometry and state. Output is the
402422 // connector showing the window, empty while it is hidden.
403423 type ConfigureWindow struct {
@@ -462,6 +482,9 @@ func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) {
462482 type SetOutputs struct {
463483 Outputs Layout
464484 Focused string
485+ // Off lists the outputs a client turned off (output power management),
486+ // in Outputs order.
487+ Off []string
465488 }
466489
467490 func (SetOutputs) clientCommand() {}
@@ -588,9 +611,12 @@ type Scene struct {
588611 Seq uint64
589612 OutputWidth, OutputHeight int
590613 Scale float64
591- Background string
592- Border Border
593- Windows []SceneWindow
614+ // Off turns the display off (output power management): nothing is
615+ // drawn until a scene without it.
616+ Off bool
617+ Background string
618+ Border Border
619+ Windows []SceneWindow
594620 // Separators are the lines between windows, drawn in slice order with
595621 // the Border colors: tile lines over the tiles, under the floats; a
596622 // float's border right after the float.
@@ -53,6 +53,15 @@ type IdleInhibit struct {
53 53
54 func (IdleInhibit) clientEvent() {}54 func (IdleInhibit) clientEvent() {}
55 55
56+// OutputPower carries wayland → core a client turning a display off or
57+// back on (zwlr_output_power_v1).
58+type OutputPower struct {
59+ Output string
60+ On bool
61+}
62+
63+func (OutputPower) clientEvent() {}
64+
56 // WindowFullscreenRequest carries wayland → core fullscreen requests.65 // WindowFullscreenRequest carries wayland → core fullscreen requests.
57 type WindowFullscreenRequest struct {66 type WindowFullscreenRequest struct {
58 ID WindowID67 ID WindowID
@@ -398,6 +407,17 @@ type ShortcutsInhibitState struct {
398 407
399 func (ShortcutsInhibitState) clientCommand() {}408 func (ShortcutsInhibitState) clientCommand() {}
400 409
410+// UserActivity carries core → wayland that the user touched an input
411+// device, for idle notifications (ext_idle_notifier_v1). Core sends at
412+// most one per ActivityInterval.
413+type UserActivity struct{}
414+
415+func (UserActivity) clientCommand() {}
416+
417+// ActivityInterval is the most often core reports UserActivity: idle
418+// timers may start up to this much early, so no idle timeout is shorter.
419+const ActivityInterval = 100 * time.Millisecond
420+
401 // ConfigureWindow carries core → wayland geometry and state. Output is the421 // ConfigureWindow carries core → wayland geometry and state. Output is the
402 // connector showing the window, empty while it is hidden.422 // connector showing the window, empty while it is hidden.
403 type ConfigureWindow struct {423 type ConfigureWindow struct {
@@ -462,6 +482,9 @@ func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) {
462 type SetOutputs struct {482 type SetOutputs struct {
463 Outputs Layout483 Outputs Layout
464 Focused string484 Focused string
485+ // Off lists the outputs a client turned off (output power management),
486+ // in Outputs order.
487+ Off []string
465 }488 }
466 489
467 func (SetOutputs) clientCommand() {}490 func (SetOutputs) clientCommand() {}
@@ -588,9 +611,12 @@ type Scene struct {
588 Seq uint64611 Seq uint64
589 OutputWidth, OutputHeight int612 OutputWidth, OutputHeight int
590 Scale float64613 Scale float64
591- Background string614+ // Off turns the display off (output power management): nothing is
592- Border Border615+ // drawn until a scene without it.
593- Windows []SceneWindow616+ Off bool
617+ Background string
618+ Border Border
619+ Windows []SceneWindow
594 // Separators are the lines between windows, drawn in slice order with620 // Separators are the lines between windows, drawn in slice order with
595 // the Border colors: tile lines over the tiles, under the floats; a621 // the Border colors: tile lines over the tiles, under the floats; a
596 // float's border right after the float.622 // float's border right after the float.

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 26

Tags 10

↑↓ moveenter openesc close