detached e2a318d

feat(wayland): ext_idle_notifier_v1 and wlr output power management

commit
e2a318d3147a75297fc4894bc78fecaa1a9d5ce4
author
brice · · 2026-09-26 18:14 UTC
parent
be59327
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +745 -21
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 +35 -1
@@ -95,6 +95,9 @@ type Output struct {
9595 vrrProp uint32
9696 vrrOn bool
9797 composedSince time.Time
98+ // off: the CRTC is inactive because a client turned the display off
99+ // (Scene.Off); cleared by every modeset.
100+ off bool
98101 // kind is how the images were made.
99102 kind imageKind
100103 // formats receives the direct scanout formats after each modeset;
@@ -423,7 +426,7 @@ func (o *Output) modeset() error {
423426 _ = o.k.destroyBlob(o.modeBlob)
424427 }
425428 o.modeBlob = blob
426- o.vrrOn, o.overlayOn = false, 0
429+ o.vrrOn, o.overlayOn, o.off = false, 0, false
427430 if o.cursor != nil {
428431 o.cursor.applied = cursorState{}
429432 o.cursor.screen, o.cursor.flying = 0, false
@@ -434,6 +437,21 @@ func (o *Output) modeset() error {
434437 return nil
435438 }
436439
440+// powerOff turns the display off: the CRTC goes inactive and keeps its
441+// mode, as DPMS off. The commit blocks, so no event is pending after it;
442+// a modeset turns the display back on.
443+func (o *Output) powerOff() error {
444+ req := &atomicReq{}
445+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
446+ req.set(o.crtc, o.vrrProp, 0)
447+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
448+ return fmt.Errorf("power off: %w", err)
449+ }
450+ o.off, o.vrrOn = true, false
451+ o.log.Info().Str("connector", o.conn.name).Msg("power off")
452+ return nil
453+}
454+
437455 // sendFormats reports the formats clients may allocate for direct
438456 // scanout on this output (none when scanout is off). It never blocks:
439457 // wayland keeps the latest per output, a dropped report is resent at the
@@ -978,6 +996,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
978996 if !enabled || o.pending {
979997 continue
980998 }
999+ // Output power: off waits for no commit in flight; on is a modeset
1000+ // and a full frame.
1001+ if haveScene && scene.Off != o.off {
1002+ var err error
1003+ if scene.Off {
1004+ err = o.powerOff()
1005+ } else if err = o.modeset(); err == nil {
1006+ dirty = true
1007+ }
1008+ if err != nil && !o.commitFailed(err, &enabled) {
1009+ return err
1010+ }
1011+ }
1012+ if o.off {
1013+ continue
1014+ }
9811015 if !dirty || !haveScene {
9821016 if stateDirty {
9831017 err := o.commitState(o.wantVRR(o.shown != 0))
@@ -95,6 +95,9 @@ type Output struct {
95 vrrProp uint3295 vrrProp uint32
96 vrrOn bool96 vrrOn bool
97 composedSince time.Time97 composedSince time.Time
98+ // off: the CRTC is inactive because a client turned the display off
99+ // (Scene.Off); cleared by every modeset.
100+ off bool
98 // kind is how the images were made.101 // kind is how the images were made.
99 kind imageKind102 kind imageKind
100 // formats receives the direct scanout formats after each modeset;103 // formats receives the direct scanout formats after each modeset;
@@ -423,7 +426,7 @@ func (o *Output) modeset() error {
423 _ = o.k.destroyBlob(o.modeBlob)426 _ = o.k.destroyBlob(o.modeBlob)
424 }427 }
425 o.modeBlob = blob428 o.modeBlob = blob
426- o.vrrOn, o.overlayOn = false, 0429+ o.vrrOn, o.overlayOn, o.off = false, 0, false
427 if o.cursor != nil {430 if o.cursor != nil {
428 o.cursor.applied = cursorState{}431 o.cursor.applied = cursorState{}
429 o.cursor.screen, o.cursor.flying = 0, false432 o.cursor.screen, o.cursor.flying = 0, false
@@ -434,6 +437,21 @@ func (o *Output) modeset() error {
434 return nil437 return nil
435 }438 }
436 439
440+// powerOff turns the display off: the CRTC goes inactive and keeps its
441+// mode, as DPMS off. The commit blocks, so no event is pending after it;
442+// a modeset turns the display back on.
443+func (o *Output) powerOff() error {
444+ req := &atomicReq{}
445+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
446+ req.set(o.crtc, o.vrrProp, 0)
447+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
448+ return fmt.Errorf("power off: %w", err)
449+ }
450+ o.off, o.vrrOn = true, false
451+ o.log.Info().Str("connector", o.conn.name).Msg("power off")
452+ return nil
453+}
454+
437 // sendFormats reports the formats clients may allocate for direct455 // sendFormats reports the formats clients may allocate for direct
438 // scanout on this output (none when scanout is off). It never blocks:456 // 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 the457 // wayland keeps the latest per output, a dropped report is resent at the
@@ -978,6 +996,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
978 if !enabled || o.pending {996 if !enabled || o.pending {
979 continue997 continue
980 }998 }
999+ // Output power: off waits for no commit in flight; on is a modeset
1000+ // and a full frame.
1001+ if haveScene && scene.Off != o.off {
1002+ var err error
1003+ if scene.Off {
1004+ err = o.powerOff()
1005+ } else if err = o.modeset(); err == nil {
1006+ dirty = true
1007+ }
1008+ if err != nil && !o.commitFailed(err, &enabled) {
1009+ return err
1010+ }
1011+ }
1012+ if o.off {
1013+ continue
1014+ }
981 if !dirty || !haveScene {1015 if !dirty || !haveScene {
982 if stateDirty {1016 if stateDirty {
983 err := o.commitState(o.wantVRR(o.shown != 0))1017 err := o.commitState(o.wantVRR(o.shown != 0))
A internal/adapters/drm/power_test.go +104 -0
@@ -0,0 +1,104 @@
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+ ctx, cancel := context.WithCancel(context.Background())
47+ done := make(chan error, 1)
48+ go func() {
49+ 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))
50+ }()
51+ on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
52+ scenes <- on
53+ <-renders
54+ var frame commitRec
55+ waitFor(t, func() bool {
56+ for _, c := range snapshot() {
57+ if c.user&3 == userFrame {
58+ frame = c
59+ return true
60+ }
61+ }
62+ return false
63+ })
64+ flips <- eventOf(frame)
65+
66+ off := on
67+ off.Off = true
68+ scenes <- off
69+ waitFor(t, func() bool {
70+ cs := snapshot()
71+ v, ok := active(cs[len(cs)-1])
72+ return ok && v == 0
73+ })
74+ n := len(snapshot())
75+ // Scenes while off draw nothing (renders before the power off drained).
76+ for len(renders) > 0 {
77+ <-renders
78+ }
79+ scenes <- off
80+ time.Sleep(20 * time.Millisecond)
81+ select {
82+ case <-renders:
83+ t.Fatal("rendered while off")
84+ default:
85+ }
86+ if len(snapshot()) != n {
87+ t.Fatal("committed while off")
88+ }
89+
90+ scenes <- on
91+ <-renders
92+ waitFor(t, func() bool {
93+ for _, c := range snapshot()[n:] {
94+ if v, ok := active(c); ok && v == 1 {
95+ return true
96+ }
97+ }
98+ return false
99+ })
100+ cancel()
101+ if err := <-done; err != nil {
102+ t.Fatal(err)
103+ }
104+}
@@ -0,0 +1,104 @@
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+ ctx, cancel := context.WithCancel(context.Background())
47+ done := make(chan error, 1)
48+ go func() {
49+ 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))
50+ }()
51+ on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
52+ scenes <- on
53+ <-renders
54+ var frame commitRec
55+ waitFor(t, func() bool {
56+ for _, c := range snapshot() {
57+ if c.user&3 == userFrame {
58+ frame = c
59+ return true
60+ }
61+ }
62+ return false
63+ })
64+ flips <- eventOf(frame)
65+
66+ off := on
67+ off.Off = true
68+ scenes <- off
69+ waitFor(t, func() bool {
70+ cs := snapshot()
71+ v, ok := active(cs[len(cs)-1])
72+ return ok && v == 0
73+ })
74+ n := len(snapshot())
75+ // Scenes while off draw nothing (renders before the power off drained).
76+ for len(renders) > 0 {
77+ <-renders
78+ }
79+ scenes <- off
80+ time.Sleep(20 * time.Millisecond)
81+ select {
82+ case <-renders:
83+ t.Fatal("rendered while off")
84+ default:
85+ }
86+ if len(snapshot()) != n {
87+ t.Fatal("committed while off")
88+ }
89+
90+ scenes <- on
91+ <-renders
92+ waitFor(t, func() bool {
93+ for _, c := range snapshot()[n:] {
94+ if v, ok := active(c); ok && v == 1 {
95+ return true
96+ }
97+ }
98+ return false
99+ })
100+ cancel()
101+ if err := <-done; err != nil {
102+ t.Fatal(err)
103+ }
104+}
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 +214 -0
@@ -0,0 +1,214 @@
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+ n.timer = time.AfterFunc(n.timeout, func() {
95+ s.display.Do(func() {
96+ // A timer stopped or replaced while it waited for Do is stale.
97+ if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
98+ return
99+ }
100+ n.idle = true
101+ n.res.SendIdled()
102+ })
103+ })
104+}
105+
106+func (n *idleNotification) stop() {
107+ n.gen++
108+ if n.timer != nil {
109+ n.timer.Stop()
110+ n.timer = nil
111+ }
112+}
113+
114+// userActivity resumes idle notifications and restarts their timers.
115+func (s *Server) userActivity() {
116+ for _, n := range s.idleNotes {
117+ if n.idle {
118+ n.idle = false
119+ n.res.SendResumed()
120+ }
121+ s.armIdle(n)
122+ }
123+}
124+
125+// syncIdle follows idle inhibitors: while one exists, inhibitable timers
126+// stop; once the last one goes, they start again from zero.
127+func (s *Server) syncIdle(wasHeld bool) {
128+ if held := len(s.idleWindows) > 0; held != wasHeld {
129+ for _, n := range s.idleNotes {
130+ if !n.input {
131+ s.armIdle(n)
132+ }
133+ }
134+ }
135+}
136+
137+type powerManager struct{ server *Server }
138+
139+func (powerManager) Destroy(*wlroutputpower.ZwlrOutputPowerManagerV1) {}
140+
141+func (m powerManager) GetOutputPower(r *wlroutputpower.ZwlrOutputPowerManagerV1, id uint32, wl *wayland.Output) {
142+ s := m.server
143+ o := s.outputOf(wl)
144+ p := &outputPower{}
145+ res, err := wlroutputpower.NewZwlrOutputPowerV1(r.Client(), r.Version(), id, powerHandler{s, p})
146+ if err != nil {
147+ return
148+ }
149+ p.res = res
150+ if o == nil {
151+ // Unplugged or unknown: the object is inert.
152+ res.SendFailed()
153+ return
154+ }
155+ p.output = o.name()
156+ s.powers = append(s.powers, p)
157+ res.OnDestroy = func() {
158+ for i, x := range s.powers {
159+ if x == p {
160+ s.powers = append(s.powers[:i], s.powers[i+1:]...)
161+ break
162+ }
163+ }
164+ }
165+ res.SendMode(powerMode(!s.outputsOff[p.output]))
166+}
167+
168+type powerHandler struct {
169+ server *Server
170+ p *outputPower
171+}
172+
173+func (powerHandler) Destroy(*wlroutputpower.ZwlrOutputPowerV1) {}
174+
175+func (h powerHandler) SetMode(r *wlroutputpower.ZwlrOutputPowerV1, mode uint32) {
176+ if mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff) && mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn) {
177+ r.PostError(uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode), "invalid mode")
178+ return
179+ }
180+ if h.p.output == "" {
181+ return
182+ }
183+ h.server.emit(ports.OutputPower{Output: h.p.output, On: mode == uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)})
184+}
185+
186+func powerMode(on bool) uint32 {
187+ if on {
188+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)
189+ }
190+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
191+}
192+
193+// setOutputsOff applies core's power state: each object of an output
194+// whose mode changed hears it; objects of gone outputs fail.
195+func (s *Server) setOutputsOff(off map[string]bool) {
196+ names := map[string]bool{}
197+ for _, o := range s.outputs {
198+ names[o.name()] = true
199+ }
200+ kept := s.powers[:0]
201+ for _, p := range s.powers {
202+ if !names[p.output] {
203+ p.res.SendFailed()
204+ p.res.OnDestroy = nil
205+ continue
206+ }
207+ if off[p.output] != s.outputsOff[p.output] {
208+ p.res.SendMode(powerMode(!off[p.output]))
209+ }
210+ kept = append(kept, p)
211+ }
212+ s.powers = kept
213+ s.outputsOff = off
214+}
@@ -0,0 +1,214 @@
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+ n.timer = time.AfterFunc(n.timeout, func() {
95+ s.display.Do(func() {
96+ // A timer stopped or replaced while it waited for Do is stale.
97+ if n.gen != gen || !n.res.Resource.Alive() || n.idle || s.idleHeld(n) {
98+ return
99+ }
100+ n.idle = true
101+ n.res.SendIdled()
102+ })
103+ })
104+}
105+
106+func (n *idleNotification) stop() {
107+ n.gen++
108+ if n.timer != nil {
109+ n.timer.Stop()
110+ n.timer = nil
111+ }
112+}
113+
114+// userActivity resumes idle notifications and restarts their timers.
115+func (s *Server) userActivity() {
116+ for _, n := range s.idleNotes {
117+ if n.idle {
118+ n.idle = false
119+ n.res.SendResumed()
120+ }
121+ s.armIdle(n)
122+ }
123+}
124+
125+// syncIdle follows idle inhibitors: while one exists, inhibitable timers
126+// stop; once the last one goes, they start again from zero.
127+func (s *Server) syncIdle(wasHeld bool) {
128+ if held := len(s.idleWindows) > 0; held != wasHeld {
129+ for _, n := range s.idleNotes {
130+ if !n.input {
131+ s.armIdle(n)
132+ }
133+ }
134+ }
135+}
136+
137+type powerManager struct{ server *Server }
138+
139+func (powerManager) Destroy(*wlroutputpower.ZwlrOutputPowerManagerV1) {}
140+
141+func (m powerManager) GetOutputPower(r *wlroutputpower.ZwlrOutputPowerManagerV1, id uint32, wl *wayland.Output) {
142+ s := m.server
143+ o := s.outputOf(wl)
144+ p := &outputPower{}
145+ res, err := wlroutputpower.NewZwlrOutputPowerV1(r.Client(), r.Version(), id, powerHandler{s, p})
146+ if err != nil {
147+ return
148+ }
149+ p.res = res
150+ if o == nil {
151+ // Unplugged or unknown: the object is inert.
152+ res.SendFailed()
153+ return
154+ }
155+ p.output = o.name()
156+ s.powers = append(s.powers, p)
157+ res.OnDestroy = func() {
158+ for i, x := range s.powers {
159+ if x == p {
160+ s.powers = append(s.powers[:i], s.powers[i+1:]...)
161+ break
162+ }
163+ }
164+ }
165+ res.SendMode(powerMode(!s.outputsOff[p.output]))
166+}
167+
168+type powerHandler struct {
169+ server *Server
170+ p *outputPower
171+}
172+
173+func (powerHandler) Destroy(*wlroutputpower.ZwlrOutputPowerV1) {}
174+
175+func (h powerHandler) SetMode(r *wlroutputpower.ZwlrOutputPowerV1, mode uint32) {
176+ if mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff) && mode != uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn) {
177+ r.PostError(uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode), "invalid mode")
178+ return
179+ }
180+ if h.p.output == "" {
181+ return
182+ }
183+ h.server.emit(ports.OutputPower{Output: h.p.output, On: mode == uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)})
184+}
185+
186+func powerMode(on bool) uint32 {
187+ if on {
188+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOn)
189+ }
190+ return uint32(wlroutputpower.ZwlrOutputPowerV1ModeOff)
191+}
192+
193+// setOutputsOff applies core's power state: each object of an output
194+// whose mode changed hears it; objects of gone outputs fail.
195+func (s *Server) setOutputsOff(off map[string]bool) {
196+ names := map[string]bool{}
197+ for _, o := range s.outputs {
198+ names[o.name()] = true
199+ }
200+ kept := s.powers[:0]
201+ for _, p := range s.powers {
202+ if !names[p.output] {
203+ p.res.SendFailed()
204+ p.res.OnDestroy = nil
205+ continue
206+ }
207+ if off[p.output] != s.outputsOff[p.output] {
208+ p.res.SendMode(powerMode(!off[p.output]))
209+ }
210+ kept = append(kept, p)
211+ }
212+ s.powers = kept
213+ s.outputsOff = off
214+}
A internal/adapters/wayland/idle_test.go +171 -0
@@ -0,0 +1,171 @@
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+}
158+
159+// An unknown mode is a protocol error.
160+func TestOutputPowerInvalidMode(t *testing.T) {
161+ s, _, _, _, dir := contentServer(t)
162+ c := protocolClient(t, s, dir)
163+ output := bindProtocol(t, c, "wl_output")
164+ registerProtocol(t, c, output)
165+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
166+ id := c.AllocateID()
167+ registerProtocol(t, c, id)
168+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
169+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, uint32(7))
170+ expectProtocolError(t, c, id, uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode))
171+}
@@ -0,0 +1,171 @@
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+}
158+
159+// An unknown mode is a protocol error.
160+func TestOutputPowerInvalidMode(t *testing.T) {
161+ s, _, _, _, dir := contentServer(t)
162+ c := protocolClient(t, s, dir)
163+ output := bindProtocol(t, c, "wl_output")
164+ registerProtocol(t, c, output)
165+ mgr := bindProtocol(t, c, "zwlr_output_power_manager_v1")
166+ id := c.AllocateID()
167+ registerProtocol(t, c, id)
168+ requestProtocol(t, c, mgr, wlroutputpower.ZwlrOutputPowerManagerV1RequestGetOutputPower, id, output)
169+ requestProtocol(t, c, id, wlroutputpower.ZwlrOutputPowerV1RequestSetMode, uint32(7))
170+ expectProtocolError(t, c, id, uint32(wlroutputpower.ZwlrOutputPowerV1ErrorInvalidMode))
171+}
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.
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.
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 25

Tags 10

↑↓ moveenter openesc close