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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 143 | 143 | ||
| 144 | 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 | 162 | ## State for scripts | |
| 147 | 163 | ||
| 148 | 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: | |
| @@ -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 scripts | 162 | ## 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 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 8 | github.com/bnema/purego-vulkan v0.4.1 | |
| 9 | 9 | github.com/bnema/wlturbo v0.2.0 | |
| 10 | 10 | 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.4 | 6 | 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 |
| 8 | github.com/bnema/purego-vulkan v0.4.1 | 8 | github.com/bnema/purego-vulkan v0.4.1 | ||
| 9 | github.com/bnema/wlturbo v0.2.0 | 9 | github.com/bnema/wlturbo v0.2.0 | ||
| 10 | github.com/bnema/zerowrap v1.4.1 | 10 | 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 { | |||
| 95 | 95 | vrrProp uint32 | |
| 96 | 96 | vrrOn bool | |
| 97 | 97 | 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 | 101 | // kind is how the images were made. | |
| 99 | 102 | kind imageKind | |
| 100 | 103 | // formats receives the direct scanout formats after each modeset; | |
| @@ -423,7 +426,7 @@ func (o *Output) modeset() error { | |||
| 423 | 426 | _ = o.k.destroyBlob(o.modeBlob) | |
| 424 | 427 | } | |
| 425 | 428 | o.modeBlob = blob | |
| 426 | - | o.vrrOn, o.overlayOn = false, 0 | |
| 429 | + | o.vrrOn, o.overlayOn, o.off = false, 0, false | |
| 427 | 430 | if o.cursor != nil { | |
| 428 | 431 | o.cursor.applied = cursorState{} | |
| 429 | 432 | o.cursor.screen, o.cursor.flying = 0, false | |
| @@ -434,6 +437,21 @@ func (o *Output) modeset() error { | |||
| 434 | 437 | 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 | 455 | // sendFormats reports the formats clients may allocate for direct | |
| 438 | 456 | // scanout on this output (none when scanout is off). It never blocks: | |
| 439 | 457 | // 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 | 996 | if !enabled || o.pending { | |
| 979 | 997 | 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 | 1015 | if !dirty || !haveScene { | |
| 982 | 1016 | if stateDirty { | |
| 983 | 1017 | err := o.commitState(o.wantVRR(o.shown != 0)) | |
| @@ -95,6 +95,9 @@ type Output struct { | |||||
| 95 | vrrProp uint32 | 95 | vrrProp uint32 | ||
| 96 | vrrOn bool | 96 | vrrOn bool | ||
| 97 | composedSince time.Time | 97 | 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 imageKind | 102 | 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 = blob | 428 | o.modeBlob = blob | ||
| 426 | - | o.vrrOn, o.overlayOn = false, 0 | 429 | + | 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, false | 432 | o.cursor.screen, o.cursor.flying = 0, false | ||
| @@ -434,6 +437,21 @@ func (o *Output) modeset() error { | |||||
| 434 | return nil | 437 | 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 direct | 455 | // 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 the | 457 | // 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 | continue | 997 | 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 | |||
| 105 | 105 | break drain | |
| 106 | 106 | } | |
| 107 | 107 | } | |
| 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. | |
| 110 | 111 | pending = opts.report(pending, seen) | |
| 111 | 112 | continue | |
| 112 | 113 | } | |
| @@ -105,8 +105,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 105 | break drain | 105 | 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 | continue | 112 | 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 { | |||
| 22 | 22 | func() error { return registerPresentation(d, s) }, | |
| 23 | 23 | func() error { return registerSyncobj(d, s) }, | |
| 24 | 24 | func() error { return registerInhibit(d, s) }, | |
| 25 | + | func() error { return registerIdle(d, s) }, | |
| 25 | 26 | func() error { return registerActivation(d, s) }, | |
| 26 | 27 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 27 | 28 | 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() { | |||
| 127 | 127 | for id, on := range diffSets(s.idleWindows, idle) { | |
| 128 | 128 | s.emit(ports.IdleInhibit{Window: id, Active: on}) | |
| 129 | 129 | } | |
| 130 | + | wasHeld := len(s.idleWindows) > 0 | |
| 130 | 131 | s.shortcutWindows, s.idleWindows = shortcuts, idle | |
| 132 | + | s.syncIdle(wasHeld) | |
| 131 | 133 | } | |
| 132 | 134 | ||
| 133 | 135 | // 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, idle | 131 | 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 { | |||
| 130 | 130 | // shortcutWindows and idleWindows what core was told. | |
| 131 | 131 | inhibitors []*inhibitor | |
| 132 | 132 | 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 | |
| 138 | 143 | // cursorSurface is the wl_pointer.set_cursor surface in use. | |
| 139 | 144 | cursorSurface *surface | |
| 140 | 145 | // cursorMu guards only the latest cursor change for forwardCursors. | |
| @@ -588,6 +593,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 588 | 593 | } | |
| 589 | 594 | case ports.SetOutputs: | |
| 590 | 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 | 603 | case ports.SlotsPending: | |
| 592 | 604 | s.slotsPending = c.Pending | |
| 593 | 605 | 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 []*inhibitor | 131 | inhibitors []*inhibitor | ||
| 132 | shortcutWindows, idleWindows map[ports.WindowID]bool | 132 | 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 []*output | 135 | + | idleNotes []*idleNotification |
| 136 | - | focusedOutput string | 136 | + | powers []*outputPower |
| 137 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 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 | |||
| 138 | // cursorSurface is the wl_pointer.set_cursor surface in use. | 143 | // cursorSurface is the wl_pointer.set_cursor surface in use. | ||
| 139 | cursorSurface *surface | 144 | 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.Pending | 604 | s.slotsPending = c.Pending | ||
| 593 | case ports.ShortcutsInhibitState: | 605 | case ports.ShortcutsInhibitState: | ||
M internal/core/core.go +14 -3
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "slices" | |
| 10 | 10 | "strconv" | |
| 11 | 11 | "strings" | |
| 12 | + | "time" | |
| 12 | 13 | "unicode" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -95,6 +96,9 @@ type Core struct { | |||
| 95 | 96 | inhibitors map[WindowID]bool | |
| 96 | 97 | inhibiting WindowID | |
| 97 | 98 | 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 | 104 | func keyName(s string) string { | |
| @@ -370,7 +374,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 370 | 374 | return err | |
| 371 | 375 | } | |
| 372 | 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 | 378 | if err := c.command(ctx, v); err != nil { | |
| 375 | 379 | return err | |
| 376 | 380 | } | |
| @@ -398,7 +402,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 398 | 402 | for i, sc := range c.screens { | |
| 399 | 403 | c.seq++ | |
| 400 | 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 | 406 | layout := sc.mon.Layout() | |
| 403 | 407 | // Only the focused output lights the focused window's lines. | |
| 404 | 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 | 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 | 551 | // allLayers lists the layer surfaces of every output. | |
| @@ -658,6 +662,10 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 658 | 662 | return nil | |
| 659 | 663 | } | |
| 660 | 664 | 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 | 669 | case ports.IdleInhibit: | |
| 662 | 670 | if v.Active { | |
| 663 | 671 | c.idle[v.Window] = true | |
| @@ -751,6 +759,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 751 | 759 | c.ch.Input = nil | |
| 752 | 760 | continue | |
| 753 | 761 | } | |
| 762 | + | if err := c.userActivity(ctx); err != nil { | |
| 763 | + | return nil | |
| 764 | + | } | |
| 754 | 765 | switch v := ev.(type) { | |
| 755 | 766 | case ports.PointerMotion: | |
| 756 | 767 | // 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]bool | 96 | inhibitors map[WindowID]bool | ||
| 96 | inhibiting WindowID | 97 | inhibiting WindowID | ||
| 97 | idle map[WindowID]bool | 98 | 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 err | 374 | 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 err | 379 | 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 nil | 662 | return nil | ||
| 659 | } | 663 | } | ||
| 660 | continue | 664 | 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] = true | 671 | c.idle[v.Window] = true | ||
| @@ -751,6 +759,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 751 | c.ch.Input = nil | 759 | c.ch.Input = nil | ||
| 752 | continue | 760 | 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 { | |||
| 28 | 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 | 33 | func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |
| 33 | 34 | t.Helper() | |
| 34 | 35 | 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 | |
| 39 | 40 | } | |
| 40 | 41 | } | |
| 41 | 42 | } | |
| @@ -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 v | 38 | + | 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 { | |||
| 14 | 14 | mon *Monitor | |
| 15 | 15 | scale, cfgScale float64 | |
| 16 | 16 | 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) | |
| 18 | 19 | layers []ports.LayerSurface | |
| 19 | 20 | placed []ports.SceneLayer | |
| 20 | 21 | } | |
| @@ -14,7 +14,8 @@ type screen struct { | |||||
| 14 | mon *Monitor | 14 | mon *Monitor | ||
| 15 | scale, cfgScale float64 | 15 | scale, cfgScale float64 | ||
| 16 | primary bool | 16 | 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) | |||
| 18 | layers []ports.LayerSurface | 19 | layers []ports.LayerSurface | ||
| 19 | placed []ports.SceneLayer | 20 | placed []ports.SceneLayer | ||
| 20 | } | 21 | } | ||
M internal/ports/messages.go +29 -3
| @@ -53,6 +53,15 @@ type IdleInhibit struct { | |||
| 53 | 53 | ||
| 54 | 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 | 65 | // WindowFullscreenRequest carries wayland → core fullscreen requests. | |
| 57 | 66 | type WindowFullscreenRequest struct { | |
| 58 | 67 | ID WindowID | |
| @@ -398,6 +407,17 @@ type ShortcutsInhibitState struct { | |||
| 398 | 407 | ||
| 399 | 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 | 421 | // ConfigureWindow carries core → wayland geometry and state. Output is the | |
| 402 | 422 | // connector showing the window, empty while it is hidden. | |
| 403 | 423 | type ConfigureWindow struct { | |
| @@ -462,6 +482,9 @@ func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) { | |||
| 462 | 482 | type SetOutputs struct { | |
| 463 | 483 | Outputs Layout | |
| 464 | 484 | 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 | 490 | func (SetOutputs) clientCommand() {} | |
| @@ -588,9 +611,12 @@ type Scene struct { | |||
| 588 | 611 | Seq uint64 | |
| 589 | 612 | OutputWidth, OutputHeight int | |
| 590 | 613 | 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 | |
| 594 | 620 | // Separators are the lines between windows, drawn in slice order with | |
| 595 | 621 | // the Border colors: tile lines over the tiles, under the floats; a | |
| 596 | 622 | // 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 WindowID | 67 | 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 the | 421 | // 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 Layout | 483 | Outputs Layout | ||
| 464 | Focused string | 484 | 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 uint64 | 611 | Seq uint64 | ||
| 589 | OutputWidth, OutputHeight int | 612 | OutputWidth, OutputHeight int | ||
| 590 | Scale float64 | 613 | Scale float64 | ||
| 591 | - | Background string | 614 | + | // Off turns the display off (output power management): nothing is |
| 592 | - | Border Border | 615 | + | // drawn until a scene without it. |
| 593 | - | Windows []SceneWindow | 616 | + | Off bool |
| 617 | + | Background string | |||
| 618 | + | Border Border | |||
| 619 | + | Windows []SceneWindow | |||
| 594 | // Separators are the lines between windows, drawn in slice order with | 620 | // Separators are the lines between windows, drawn in slice order with | ||
| 595 | // the Border colors: tile lines over the tiles, under the floats; a | 621 | // 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. | ||