Skip to content

Commit 77a203b

Browse files
authored
feat(windows): publish display changes as screen-parameter events (#1573)
1 parent fd20eb4 commit 77a203b

9 files changed

Lines changed: 468 additions & 29 deletions

File tree

docs/CROSS_PLATFORM.md

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ that is what [Known Gaps](#known-gaps) tracks, per
170170
| Capability | macOS | Linux X11 | Linux Wayland (wlroots) | Linux Wayland (KDE) | Windows |
171171
| ----------------------------- | ------------------------ | ---------------------- | ---------------------------- | ----------------------- | ---------------------------- |
172172
| **Screen bounds / enumeration** | ✅ Cocoa | ✅ XRandR | ✅ xdg-output | ✅ xdg-output |`EnumDisplayMonitors` |
173-
| **Display hotplug events** | ✅ screen-params notif. | ✅ RandR event fd |`wl_output` events |`wl_output` events | 🟡 |
173+
| **Display hotplug events** | ✅ screen-params notif. | ✅ RandR event fd |`wl_output` events |`wl_output` events | `WM_DISPLAYCHANGE` |
174174
| **Focused app identity** | ✅ NSWorkspace + AX |`_NET_ACTIVE_WINDOW` / `WM_CLASS` | ⚠️ app_id only (see below) | ⚠️ app_id only |`GetForegroundWindow` |
175175
| **App watcher (focus change)**| ✅ NSWorkspace observer | ✅ event-driven | ✅ event-driven | ✅ event-driven |`SetWinEventHook` |
176176
| **Keymap learns the focused app** | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher |
@@ -493,8 +493,13 @@ Control events remain macOS-only. Windows has a single API for it after all:
493493
through `SetWinEventHook` on a message-loop thread of its own, hands each new
494494
foreground HWND to a goroutine, and resolves it there to the **executable
495495
path** — the identity `GetForegroundWindow` already gives the focused app, so
496-
per-app configuration keys on one string however it is learned. Only
497-
activate and deactivate are emitted there; display hotplug is still a gap.
496+
per-app configuration keys on one string however it is learned. Display
497+
changes ride the same dispatch goroutine from a second source: a hidden
498+
top-level window on a pump thread of its own receives `WM_DISPLAYCHANGE` and
499+
`WM_DPICHANGED` (`platform/windows/display_watcher.go`) and coalesces them into
500+
one screen-parameters event, so a resolution or arrangement change re-lays-out
501+
the overlay as it does on macOS. Only activate, deactivate and screen-params
502+
are emitted there; launch, terminate and Mission Control stay macOS-only.
498503

499504
**Global hotkeys on Wayland.** No Wayland protocol lets an ordinary client
500505
register a global hotkey, so Neru reads `/dev/input/event*` directly with a
@@ -869,7 +874,7 @@ important thing to know before touching overlay code:
869874
| **Always on top** | `NSScreenSaverWindowLevel` | `_NET_WM_STATE_ABOVE` + `MapRaised` | overlay layer | `HWND_TOPMOST` |
870875
| **Focus prevention** | non-activating panel | `override_redirect=YES` | controlled keyboard interactivity | `WS_EX_NOACTIVATE` |
871876
| **HiDPI** | dynamic `contentsScale` + backing-change callback | `Xft.dpi`, one global factor | `wl_output` scale + `wp_fractional_scale_v1` / `wp_viewporter` | not explicit |
872-
| **Multi-monitor** | per-display clamping, screen-change tracking | all monitors enumerated, per-monitor render, live RandR hotplug | one `wl_surface` per output (max 16), live hotplug | cursor-screen tracking, separate indicator/sticky windows |
877+
| **Multi-monitor** | per-display clamping, screen-change tracking | all monitors enumerated, per-monitor render, live RandR hotplug | one `wl_surface` per output (max 16), live hotplug | cursor-screen tracking, live `WM_DISPLAYCHANGE` hotplug, separate indicator/sticky windows |
873878
| **Buffers** | layer-backed, OS-managed | single Cairo surface | triple-buffered SHM pool | single pixel buffer |
874879
| **Rounded rects / borders** | NSBezierPath | Cairo arc path + stroke | Cairo arc path + stroke | software SDF fill + multi-pass stroke |
875880
| **Text** | NSFontManager | Cairo `select_font_face` / `show_text` | Cairo `select_font_face` / `show_text` | GDI `CreateFontW` + `DrawTextW` + alpha composite |
@@ -1200,21 +1205,20 @@ working, which is exactly why the build exists.
12001205

12011206
**Windows**
12021207

1203-
1. Display hotplug — no screen-parameter change events
1204-
2. Native notifications — no toast support
1205-
3. UIA tree depth — shallow walk; complex apps under-report clickable elements
1206-
4. Grid and recursive-grid transition animation — not implemented
1207-
5. Grid virtual-pointer indicator — a no-op, while recursive grid draws it.
1208+
1. Native notifications — no toast support
1209+
2. UIA tree depth — shallow walk; complex apps under-report clickable elements
1210+
3. Grid and recursive-grid transition animation — not implemented
1211+
4. Grid virtual-pointer indicator — a no-op, while recursive grid draws it.
12081212
`virtual_pointer.ui.*` is therefore partly inert here rather than wholly, so
12091213
it stays declared everywhere and is tracked as this entry instead
1210-
6. Smooth cursor and smooth scroll animation — not implemented
1211-
7. Modifier passthrough and `PostModifierEvent` — no-ops
1212-
8. Horizontal scroll — `ScrollAtCursor` ignores `deltaX`
1213-
9. `monitor_select` mode — returns `CodeNotSupported`
1214-
10. Font resolution — alias mapping only, no system font enumeration
1215-
11. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
1214+
5. Smooth cursor and smooth scroll animation — not implemented
1215+
6. Modifier passthrough and `PostModifierEvent` — no-ops
1216+
7. Horizontal scroll — `ScrollAtCursor` ignores `deltaX`
1217+
8. `monitor_select` mode — returns `CodeNotSupported`
1218+
9. Font resolution — alias mapping only, no system font enumeration
1219+
10. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
12161220
installs a launchd agent and Linux a systemd user unit
1217-
12. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
1221+
11. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
12181222
every platform, but only the Unix client checks that for itself before
12191223
connecting. A named pipe carries no ownership a client can read without
12201224
opening it, so the Windows CLI trusts the name it derives from its own SID.

docs/ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ The two largest open areas:
4343
- **Linux** — Wayland global hotkeys, which need `input`-group membership and a
4444
CGO build rather than missing code, and whose remaining work is failing
4545
loudly with the remedy.
46-
- **Windows**display-hotplug events, which currently block monitor
47-
tracking.
46+
- **Windows**the remaining Known Gaps entries: notifications, UIA tree
47+
depth, animations, horizontal scroll and `monitor_select`.
4848

4949
GNOME Wayland remains unsupported; the daemon refuses to start there. Reviving
5050
it needs libei plus a GNOME Shell extension — see

internal/adapter/appwatcher/doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
// platform_other.go as the no-op fallback), so this package compiles on all
77
// platforms. On macOS the events come from the Objective-C NSWorkspace
88
// observer, on Linux from a compositor or X11 focus source, and on Windows
9-
// from an EVENT_SYSTEM_FOREGROUND hook.
9+
// from an EVENT_SYSTEM_FOREGROUND hook plus a hidden window's WM_DISPLAYCHANGE.
1010
package appwatcher

internal/adapter/appwatcher/platform_windows.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import (
2121
// GetForegroundWindow resolves to for the focused app, so per-app
2222
// configuration keys on one string whichever way it is learned.
2323
//
24+
// Display changes take the same route from a second source: a hidden window
25+
// (winplatform.StartDisplayWatcher) receives WM_DISPLAYCHANGE and
26+
// WM_DPICHANGED on its own pump thread, offers a token to a one-slot channel,
27+
// and the dispatch goroutine turns it into HandleScreenParametersChanged —
28+
// the event macOS's screen-parameters notification and Linux's RandR fd
29+
// produce, so the app re-lays-out the overlay the same way on all three.
30+
//
2431
// globalWindowsWatcher is the process-wide backend, mirroring
2532
// globalLinuxWatcher. NewWatcher registers itself here via
2633
// platformRegisterWatcher.
@@ -33,6 +40,14 @@ var globalWindowsWatcher = &windowsAppWatcher{
3340

3441
return hook.Stop, nil
3542
},
43+
subscribeDisplay: func(callback func()) (func(), error) {
44+
watcher, err := winplatform.StartDisplayWatcher(callback)
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
return watcher.Stop, nil
50+
},
3651
foreground: func() uintptr {
3752
hwnd, _ := winplatform.ForegroundWindowHandle()
3853

@@ -47,6 +62,9 @@ type windowsAppWatcher struct {
4762
// subscribe installs the foreground hook and returns its stop function;
4863
// injectable for tests.
4964
subscribe func(callback func(uintptr)) (func(), error)
65+
// subscribeDisplay installs the display-change window and returns its stop
66+
// function; injectable for tests.
67+
subscribeDisplay func(callback func()) (func(), error)
5068
// foreground samples the current foreground HWND once at start, so the
5169
// keymap has a published app before the first switch.
5270
foreground func() uintptr
@@ -58,7 +76,9 @@ type windowsAppWatcher struct {
5876
watcher *Watcher
5977
stopCh chan struct{}
6078
unhook func()
61-
wg sync.WaitGroup
79+
// unhookDisplay stops the display watcher; nil when it failed to install.
80+
unhookDisplay func()
81+
wg sync.WaitGroup
6282

6383
// lastID and lastName are the most recently dispatched identity ("" means
6484
// none focused). Owned by the dispatch goroutine, so they need no lock.
@@ -86,6 +106,7 @@ func (l *windowsAppWatcher) start() {
86106
}
87107

88108
events := make(chan uintptr, 1)
109+
displayEvents := make(chan struct{}, 1)
89110
l.lastID, l.lastName = "", ""
90111

91112
unhook, err := l.subscribe(func(hwnd uintptr) { offer(events, hwnd) })
@@ -108,12 +129,24 @@ func (l *windowsAppWatcher) start() {
108129
default:
109130
}
110131

132+
// A display watcher that fails to install costs only hotplug: the
133+
// foreground hook is already live and the overlay is still re-sized on
134+
// each activation, so the watcher runs on without it and says so.
135+
unhookDisplay, err := l.subscribeDisplay(func() { offerToken(displayEvents) })
136+
if err != nil {
137+
l.watcher.logger.Warn(
138+
"App watcher: display-change window install failed; overlays follow display changes on the next activation only",
139+
zap.Error(err),
140+
)
141+
}
142+
111143
l.unhook = unhook
144+
l.unhookDisplay = unhookDisplay
112145
l.stopCh = make(chan struct{})
113146

114147
l.wg.Add(1)
115148

116-
go l.loop(l.stopCh, events)
149+
go l.loop(l.stopCh, events, displayEvents)
117150
}
118151

119152
// stop unhooks first, so nothing offers after the loop is told to exit, then
@@ -127,12 +160,17 @@ func (l *windowsAppWatcher) stop() {
127160
return
128161
}
129162

130-
stopCh, unhook := l.stopCh, l.unhook
131-
l.stopCh, l.unhook = nil, nil
163+
stopCh, unhook, unhookDisplay := l.stopCh, l.unhook, l.unhookDisplay
164+
l.stopCh, l.unhook, l.unhookDisplay = nil, nil, nil
132165

133166
l.mu.Unlock()
134167

135168
unhook()
169+
170+
if unhookDisplay != nil {
171+
unhookDisplay()
172+
}
173+
136174
close(stopCh)
137175
l.wg.Wait()
138176
}
@@ -156,7 +194,22 @@ func offer(events chan uintptr, hwnd uintptr) {
156194
}
157195
}
158196

159-
func (l *windowsAppWatcher) loop(stopCh <-chan struct{}, events <-chan uintptr) {
197+
// offerToken is offer for the display channel: a burst of display messages
198+
// coalesces to a single pending refresh, because the refresh re-reads the
199+
// display and has nothing to learn from the count. It runs on the display
200+
// watcher's pump thread.
201+
func offerToken(events chan struct{}) {
202+
select {
203+
case events <- struct{}{}:
204+
default:
205+
}
206+
}
207+
208+
func (l *windowsAppWatcher) loop(
209+
stopCh <-chan struct{},
210+
events <-chan uintptr,
211+
displayEvents <-chan struct{},
212+
) {
160213
defer l.wg.Done()
161214

162215
for {
@@ -165,6 +218,8 @@ func (l *windowsAppWatcher) loop(stopCh <-chan struct{}, events <-chan uintptr)
165218
return
166219
case hwnd := <-events:
167220
l.tick(hwnd)
221+
case <-displayEvents:
222+
l.watcher.HandleScreenParametersChanged()
168223
}
169224
}
170225
}

internal/adapter/appwatcher/platform_windows_test.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
const (
1313
kindActivate = "activate"
1414
kindDeactivate = "deactivate"
15+
kindScreen = "screen"
1516

1617
hwndNotepad = uintptr(0x10)
1718
hwndCode = uintptr(0x20)
@@ -113,6 +114,41 @@ func (f *fakeHook) fire(hwnd uintptr) {
113114
}
114115
}
115116

117+
// fakeDisplayHook stands in for the hidden display-change window.
118+
type fakeDisplayHook struct {
119+
mu sync.Mutex
120+
callback func()
121+
unhooked bool
122+
err error
123+
}
124+
125+
func (f *fakeDisplayHook) subscribe(callback func()) (func(), error) {
126+
if f.err != nil {
127+
return nil, f.err
128+
}
129+
130+
f.mu.Lock()
131+
f.callback = callback
132+
f.mu.Unlock()
133+
134+
return func() {
135+
f.mu.Lock()
136+
f.unhooked = true
137+
f.callback = nil
138+
f.mu.Unlock()
139+
}, nil
140+
}
141+
142+
func (f *fakeDisplayHook) fire() {
143+
f.mu.Lock()
144+
callback := f.callback
145+
f.mu.Unlock()
146+
147+
if callback != nil {
148+
callback()
149+
}
150+
}
151+
116152
func identityFor(hwnd uintptr) (string, string, bool) {
117153
switch hwnd {
118154
case hwndNotepad:
@@ -125,16 +161,26 @@ func identityFor(hwnd uintptr) (string, string, bool) {
125161
}
126162

127163
func newTestBackend(hook *fakeHook, foreground uintptr) (*windowsAppWatcher, *eventRecorder) {
164+
return newTestBackendWithDisplay(hook, &fakeDisplayHook{}, foreground)
165+
}
166+
167+
func newTestBackendWithDisplay(
168+
hook *fakeHook,
169+
display *fakeDisplayHook,
170+
foreground uintptr,
171+
) (*windowsAppWatcher, *eventRecorder) {
128172
watcher := NewWatcher(nil)
129173
recorder := &eventRecorder{}
130174

131175
watcher.OnActivate(func(name, bundle string) { recorder.add(kindActivate, name, bundle) })
132176
watcher.OnDeactivate(func(name, bundle string) { recorder.add(kindDeactivate, name, bundle) })
177+
watcher.OnScreenParametersChanged(func() { recorder.add(kindScreen, "", "") })
133178

134179
backend := &windowsAppWatcher{
135-
subscribe: hook.subscribe,
136-
foreground: func() uintptr { return foreground },
137-
identity: identityFor,
180+
subscribe: hook.subscribe,
181+
subscribeDisplay: display.subscribe,
182+
foreground: func() uintptr { return foreground },
183+
identity: identityFor,
138184
}
139185
backend.register(watcher)
140186

@@ -207,3 +253,37 @@ func TestWindowsAppWatcher_Start_HookFailureLeavesWatcherIdle(t *testing.T) {
207253

208254
recorder.waitFor(t, nil)
209255
}
256+
257+
func TestWindowsAppWatcher_Start_PublishesDisplayChanges(t *testing.T) {
258+
display := &fakeDisplayHook{}
259+
backend, recorder := newTestBackendWithDisplay(&fakeHook{}, display, hwndDesktop)
260+
261+
backend.start()
262+
defer backend.stop()
263+
264+
display.fire()
265+
recorder.waitFor(t, []watchEvent{{kindScreen, "", ""}})
266+
267+
backend.stop()
268+
269+
if !display.unhooked {
270+
t.Fatal("stop did not stop the display watcher")
271+
}
272+
273+
display.fire()
274+
time.Sleep(20 * time.Millisecond)
275+
276+
recorder.waitFor(t, []watchEvent{{kindScreen, "", ""}})
277+
}
278+
279+
func TestWindowsAppWatcher_Start_DisplayWatcherFailureKeepsForegroundLive(t *testing.T) {
280+
hook := &fakeHook{}
281+
display := &fakeDisplayHook{err: errHookInstall}
282+
backend, recorder := newTestBackendWithDisplay(hook, display, hwndDesktop)
283+
284+
backend.start()
285+
defer backend.stop()
286+
287+
hook.fire(hwndNotepad)
288+
recorder.waitFor(t, []watchEvent{{kindActivate, nameNotepad, pathNotepad}})
289+
}

0 commit comments

Comments
 (0)