Skip to content

Commit 1fd81c2

Browse files
authored
fix: react to screen changes and support extended displays for grid (#116)
1 parent 6016fce commit 1fd81c2

11 files changed

Lines changed: 257 additions & 64 deletions

File tree

cmd/neru/lifecycle.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,61 @@ func (a *App) setupAppWatcherCallbacks() {
4545
a.appWatcher.OnActivate(func(appName, bundleID string) {
4646
a.handleAppActivation(bundleID)
4747
})
48+
// Watch for display parameter changes (monitor unplug/plug, resolution changes)
49+
a.appWatcher.OnScreenParametersChanged(func() {
50+
a.handleScreenParametersChange()
51+
})
52+
}
53+
54+
// handleScreenParametersChange handles display changes and resizes/regenerates overlays
55+
func (a *App) handleScreenParametersChange() {
56+
if a.screenChangeProcessing {
57+
return
58+
}
59+
a.screenChangeProcessing = true
60+
defer func() { a.screenChangeProcessing = false }()
61+
62+
a.logger.Info("Screen parameters changed; adjusting overlays")
63+
// Only act if grid is enabled
64+
if a.config.Grid.Enabled && a.gridCtx != nil && a.gridCtx.gridOverlay != nil {
65+
// If grid mode is not active, mark for refresh on next activation
66+
if a.currentMode != ModeGrid {
67+
a.gridOverlayNeedsRefresh = true
68+
return
69+
}
70+
71+
// Grid mode is active - resize the existing overlay window to match new screen bounds
72+
gridOverlay := *a.gridCtx.gridOverlay
73+
74+
// Resize overlay window to current active screen (where mouse is)
75+
gridOverlay.ResizeToActiveScreen()
76+
77+
// Give the UI thread a moment to complete the resize
78+
time.Sleep(150 * time.Millisecond)
79+
80+
// Regenerate the grid cells with updated screen bounds
81+
if err := a.setupGrid(a.gridCtx.currentAction); err != nil {
82+
a.logger.Error("Failed to refresh grid after screen change", zap.Error(err))
83+
return
84+
}
85+
86+
a.logger.Info("Grid overlay resized and regenerated for new screen bounds")
87+
}
4888
}
4989

5090
// handleAppActivation handles application activation events
5191
func (a *App) handleAppActivation(bundleID string) {
5292
a.logger.Debug("App activated", zap.String("bundle_id", bundleID))
5393

5494
// refresh hotkeys for app
55-
go a.refreshHotkeysForAppOrCurrent(bundleID)
56-
a.logger.Debug("Handled hotkey refresh")
95+
if a.currentMode == ModeIdle {
96+
go a.refreshHotkeysForAppOrCurrent(bundleID)
97+
a.logger.Debug("Handled hotkey refresh")
98+
} else {
99+
// Defer hotkey refresh to avoid re-entry during active modes
100+
a.hotkeyRefreshPending = true
101+
a.logger.Debug("Deferred hotkey refresh due to active mode")
102+
}
57103

58104
if a.config.Hints.Enabled {
59105
if a.config.Accessibility.AdditionalAXSupport.Enable {

cmd/neru/main.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,11 @@ type App struct {
6969
gridRouter *grid.Router
7070
gridCtx *GridContext
7171

72-
enabled bool
73-
hotkeysRegistered bool
72+
enabled bool
73+
hotkeysRegistered bool
74+
screenChangeProcessing bool
75+
gridOverlayNeedsRefresh bool
76+
hotkeyRefreshPending bool
7477
}
7578

7679
// NewApp creates a new application instance

cmd/neru/modes.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"image"
66
"strings"
7+
"time"
78

89
"github.com/y3owk1n/neru/internal/accessibility"
910
"github.com/y3owk1n/neru/internal/bridge"
@@ -147,12 +148,18 @@ func (a *App) activateGridMode(action Action) {
147148

148149
a.exitMode() // Exit current mode first
149150

150-
if actionString == "unknown" {
151-
a.logger.Warn("Unknown action, ignoring")
152-
return
151+
// Always resize overlay to the active screen (where mouse is) before drawing grid
152+
if a.gridCtx != nil && a.gridCtx.gridOverlay != nil {
153+
(*a.gridCtx.gridOverlay).ResizeToActiveScreen()
154+
// Wait for async resize to complete on main thread
155+
time.Sleep(100 * time.Millisecond)
156+
}
157+
158+
// If screen changed while grid was inactive, clear the refresh flag
159+
if a.gridOverlayNeedsRefresh {
160+
a.gridOverlayNeedsRefresh = false
153161
}
154162

155-
// Generate grid cells
156163
if err := a.setupGrid(action); err != nil {
157164
a.logger.Error("Failed to setup grid", zap.Error(err), zap.String("action", actionString))
158165
return
@@ -176,7 +183,12 @@ func (a *App) activateGridMode(action Action) {
176183
func (a *App) setupGrid(action Action) error {
177184
// Create grid with active screen bounds (screen containing mouse cursor)
178185
// This ensures proper multi-monitor support
179-
bounds := bridge.GetActiveScreenBounds()
186+
screenBounds := bridge.GetActiveScreenBounds()
187+
188+
// Normalize bounds to window-local coordinates (0,0 origin)
189+
// The overlay window is positioned at the screen origin, but the view uses local coordinates
190+
bounds := image.Rect(0, 0, screenBounds.Dx(), screenBounds.Dy())
191+
180192
characters := a.config.Grid.Characters
181193
if strings.TrimSpace(characters) == "" {
182194
characters = a.config.Hints.HintCharacters
@@ -920,6 +932,12 @@ func (a *App) exitMode() {
920932
a.currentAction = ActionLeftClick
921933
a.logger.Debug("Mode transition complete",
922934
zap.String("to", "idle"))
935+
936+
// If a hotkey refresh was deferred while in an active mode, perform it now
937+
if a.hotkeyRefreshPending {
938+
a.hotkeyRefreshPending = false
939+
go a.refreshHotkeysForAppOrCurrent("")
940+
}
923941
}
924942

925943
func getModeString(mode Mode) string {

internal/appwatcher/watcher.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ type AppCallback func(appName string, bundleID string)
1313
type Watcher struct {
1414
mu sync.RWMutex
1515
// Callbacks for different events
16-
launchCallbacks []AppCallback
17-
terminateCallbacks []AppCallback
18-
activateCallbacks []AppCallback
19-
deactivateCallbacks []AppCallback
16+
launchCallbacks []AppCallback
17+
terminateCallbacks []AppCallback
18+
activateCallbacks []AppCallback
19+
deactivateCallbacks []AppCallback
20+
screenChangeCallbacks []func()
2021
}
2122

2223
func NewWatcher() *Watcher {
@@ -35,11 +36,11 @@ func (w *Watcher) Stop() {
3536
bridge.StopAppWatcher()
3637
}
3738

38-
// OnLaunch registers a callback for application launch events
39-
func (w *Watcher) OnLaunch(callback AppCallback) {
39+
// OnScreenParametersChanged registers a callback for screen parameter change events
40+
func (w *Watcher) OnScreenParametersChanged(callback func()) {
4041
w.mu.Lock()
4142
defer w.mu.Unlock()
42-
w.launchCallbacks = append(w.launchCallbacks, callback)
43+
w.screenChangeCallbacks = append(w.screenChangeCallbacks, callback)
4344
}
4445

4546
// OnTerminate registers a callback for application termination events
@@ -98,3 +99,12 @@ func (w *Watcher) HandleDeactivate(appName, bundleID string) {
9899
callback(appName, bundleID)
99100
}
100101
}
102+
103+
// HandleScreenParametersChanged is called from the bridge when display parameters change
104+
func (w *Watcher) HandleScreenParametersChanged() {
105+
w.mu.RLock()
106+
defer w.mu.RUnlock()
107+
for _, callback := range w.screenChangeCallbacks {
108+
callback()
109+
}
110+
}

internal/bridge/appwatcher.m

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
extern void handleAppTerminate(const char* appName, const char* bundleID);
66
extern void handleAppActivate(const char* appName, const char* bundleID);
77
extern void handleAppDeactivate(const char* appName, const char* bundleID);
8+
extern void handleScreenParametersChanged(void);
89

910
@interface AppWatcherDelegate : NSObject
1011
@end
@@ -108,6 +109,15 @@ - (void)applicationDidDeactivate:(NSNotification *)notification {
108109
}
109110
}
110111

112+
- (void)screenParametersDidChange:(NSNotification *)notification {
113+
@autoreleasepool {
114+
// Debounce to allow system to settle, then invoke Go handler on watcherQueue (not main thread)
115+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
116+
handleScreenParametersChanged();
117+
});
118+
}
119+
}
120+
111121
@end
112122

113123
static AppWatcherDelegate *delegate = nil;
@@ -145,6 +155,12 @@ void startAppWatcher(void) {
145155
selector:@selector(applicationDidDeactivate:)
146156
name:NSWorkspaceDidDeactivateApplicationNotification
147157
object:nil];
158+
159+
// Observe screen parameter changes (display add/remove, resolution changes)
160+
[[NSNotificationCenter defaultCenter] addObserver:delegate
161+
selector:@selector(screenParametersDidChange:)
162+
name:NSApplicationDidChangeScreenParametersNotification
163+
object:nil];
148164
}
149165
});
150166
}

internal/bridge/bridge.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type AppWatcher interface {
5959
HandleTerminate(appName, bundleID string)
6060
HandleActivate(appName, bundleID string)
6161
HandleDeactivate(appName, bundleID string)
62+
HandleScreenParametersChanged()
6263
}
6364

6465
// SetAppWatcher sets the application watcher implementation
@@ -103,6 +104,13 @@ func handleAppActivate(cAppName *C.char, cBundleID *C.char) {
103104
}
104105
}
105106

107+
//export handleScreenParametersChanged
108+
func handleScreenParametersChanged() {
109+
if appWatcher != nil {
110+
go appWatcher.HandleScreenParametersChanged()
111+
}
112+
}
113+
106114
//export handleAppDeactivate
107115
func handleAppDeactivate(cAppName *C.char, cBundleID *C.char) {
108116
if appWatcher != nil {
@@ -112,17 +120,6 @@ func handleAppDeactivate(cAppName *C.char, cBundleID *C.char) {
112120
}
113121
}
114122

115-
// GetMainScreenBounds returns the bounds of the main screen
116-
func GetMainScreenBounds() image.Rectangle {
117-
rect := C.getMainScreenBounds()
118-
return image.Rect(
119-
int(rect.origin.x),
120-
int(rect.origin.y),
121-
int(rect.origin.x+rect.size.width),
122-
int(rect.origin.y+rect.size.height),
123-
)
124-
}
125-
126123
// GetActiveScreenBounds returns the bounds of the screen containing the mouse cursor
127124
func GetActiveScreenBounds() image.Rectangle {
128125
rect := C.getActiveScreenBounds()

internal/bridge/eventtap.m

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,15 @@ EventTap createEventTap(EventTapCallback callback, void* userData) {
238238

239239
context->runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, context->eventTap, 0);
240240

241+
// Add to main run loop once during creation to avoid re-entry on enable
242+
if ([NSThread isMainThread]) {
243+
CFRunLoopAddSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
244+
} else {
245+
dispatch_async(dispatch_get_main_queue(), ^{
246+
CFRunLoopAddSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
247+
});
248+
}
249+
241250
return (EventTap)context;
242251
}
243252

@@ -268,33 +277,24 @@ void enableEventTap(EventTap tap) {
268277

269278
EventTapContext* context = (EventTapContext*)tap;
270279

271-
// Must run on main thread since we're modifying the main run loop
272-
if ([NSThread isMainThread]) {
273-
CFRunLoopAddSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
274-
CGEventTapEnable(context->eventTap, true);
275-
} else {
276-
dispatch_sync(dispatch_get_main_queue(), ^{
277-
CFRunLoopAddSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
280+
// Always enable asynchronously to avoid overlap with disable/destroy
281+
// Use a short delay to ensure prior disable completes first
282+
dispatch_async(dispatch_get_main_queue(), ^{
283+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
278284
CGEventTapEnable(context->eventTap, true);
279285
});
280-
}
286+
});
281287
}
282288

283289
void disableEventTap(EventTap tap) {
284290
if (!tap) return;
285291

286292
EventTapContext* context = (EventTapContext*)tap;
287293

288-
// Must run on main thread since we're modifying the main run loop
289-
if ([NSThread isMainThread]) {
294+
// Always disable asynchronously to avoid overlap with enable/destroy
295+
dispatch_async(dispatch_get_main_queue(), ^{
290296
CGEventTapEnable(context->eventTap, false);
291-
CFRunLoopRemoveSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
292-
} else {
293-
dispatch_sync(dispatch_get_main_queue(), ^{
294-
CGEventTapEnable(context->eventTap, false);
295-
CFRunLoopRemoveSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
296-
});
297-
}
297+
});
298298
}
299299

300300
void destroyEventTap(EventTap tap) {
@@ -311,7 +311,7 @@ void destroyEventTap(EventTap tap) {
311311
CFRunLoopRemoveSource(CFRunLoopGetMain(), context->runLoopSource, kCFRunLoopCommonModes);
312312
}
313313
} else {
314-
dispatch_sync(dispatch_get_main_queue(), ^{
314+
dispatch_async(dispatch_get_main_queue(), ^{
315315
if (context->eventTap) {
316316
CGEventTapEnable(context->eventTap, false);
317317
}

internal/bridge/overlay.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ void drawHints(OverlayWindow window, HintData* hints, int count, HintStyle style
6363
void drawScrollHighlight(OverlayWindow window, CGRect bounds, char* color, int width);
6464
void setOverlayLevel(OverlayWindow window, int level);
6565
void drawTargetDot(OverlayWindow window, CGPoint center, double radius, const char *color, const char *borderColor, double borderWidth);
66+
void replaceOverlayWindow(OverlayWindow *pwindow);
67+
void resizeOverlayToMainScreen(OverlayWindow window);
68+
void resizeOverlayToActiveScreen(OverlayWindow window);
6669

6770
// Grid-specific drawing functions
6871
void drawGridCells(OverlayWindow window, GridCell* cells, int count, GridCellStyle style);

0 commit comments

Comments
 (0)