Skip to content

Commit d1b68fa

Browse files
v1.4.1: fix Match Manager not opening after display changes
Opening Match Manager from the menu bar could silently do nothing after disconnecting an external monitor or switching to the built-in display. The off-screen guard treated any overlap as "visible", so a window left straddling a screen edge was ordered front where it couldn't be seen. Recenter unless a real fraction of the window is reachable on a current display (pure, unit-tested placement helper), and use the explicit activation call that's reliable for an accessory (LSUIElement) app.
1 parent cff9636 commit d1b68fa

9 files changed

Lines changed: 108 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## [1.4.1] - 2026-06-28
4+
5+
### Fixed
6+
- **Match Manager sometimes didn't open after switching displays** — opening Match Manager from the menu bar could silently do nothing, most often right after disconnecting an external monitor or switching to the built-in display. A display change can leave the window straddling a screen edge with only a sliver on screen; the previous check treated any overlap as "visible", so the window was ordered front where you couldn't see it. It now recenters unless a real portion of the window is reachable on a current display, and the app is brought forward more reliably as a menu-bar (accessory) app.
7+
38
## [1.4.0] - 2026-06-10
49

510
### Fixed

Casks/macspanso.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cask "macspanso" do
2-
version "1.4.0"
2+
version "1.4.1"
33
sha256 "PLACEHOLDER_UPDATED_BY_RELEASE_WORKFLOW"
44

55
url "https://github.com/jeffcaldwellca/macspanso/releases/download/v#{version}/macspanso-#{version}.dmg"

docs/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"@type": "SoftwareApplication",
5151
"name": "macspanso",
5252
"alternateName": "macspanso — espanso GUI for macOS",
53-
"softwareVersion": "1.4.0",
53+
"softwareVersion": "1.4.1",
5454
"operatingSystem": "macOS 13+",
5555
"applicationCategory": "UtilitiesApplication",
5656
"applicationSubCategory": "Text Expander",
@@ -1122,7 +1122,7 @@ <h2 id="faq-h">Frequently asked</h2>
11221122
<a href="https://github.com/jeffcaldwellca/macspanso/issues">Issues</a>
11231123
<a href="https://github.com/jeffcaldwellca/macspanso/blob/main/LICENSE">License</a>
11241124
</nav>
1125-
<div class="footer-meta">macspanso v1.4.0 · MIT licensed · Made for the espanso community.</div>
1125+
<div class="footer-meta">macspanso v1.4.1 · MIT licensed · Made for the espanso community.</div>
11261126
</footer>
11271127

11281128
<!-- Live region for copy-success announcements -->

macspanso.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
8E6B0EA0BA6FB4A43B1AB88E /* EspansoConfigStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5970F4B2C15BC128D9A8456 /* EspansoConfigStoreTests.swift */; };
3434
8ECA817CBF7170DADD934DDA /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 5FB62BECB934B23CAC5FA023 /* Yams */; };
3535
8FD245E778174C4BE5A673FF /* EspansoConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BA586C50EFE4FBCE76492D /* EspansoConfigStore.swift */; };
36+
905D298986BDFED83E222004 /* MatchManagerWindowPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97856E02AB8BDCBA8D68DF7E /* MatchManagerWindowPlacementTests.swift */; };
3637
91C4437F5DD9899E51510D52 /* IDReassociationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C313B2ED2B094696683AD0D1 /* IDReassociationTests.swift */; };
3738
940CB95865F935DBDBDDDB21 /* macspansoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73300D0AA66B88477808373 /* macspansoApp.swift */; };
3839
945285C3E38182EB610CD985 /* FormField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DC13EF64A6AE194B8E4329 /* FormField.swift */; };
@@ -96,6 +97,7 @@
9697
8509E46B021A8471FDA9983C /* YAMLAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YAMLAny.swift; sourceTree = "<group>"; };
9798
89DC13EF64A6AE194B8E4329 /* FormField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormField.swift; sourceTree = "<group>"; };
9899
9139A78FA597BE9FF574C077 /* VariableBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableBuilderView.swift; sourceTree = "<group>"; };
100+
97856E02AB8BDCBA8D68DF7E /* MatchManagerWindowPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchManagerWindowPlacementTests.swift; sourceTree = "<group>"; };
99101
9F15E7CE6C04870BF8BD59FD /* MatchValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchValidator.swift; sourceTree = "<group>"; };
100102
A0E594E96E837F06FCDA74BD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
101103
AC6E6239414178F116D8F07F /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -170,6 +172,7 @@
170172
582A3F021D21611324D06406 /* EspansoProcessManagerTests.swift */,
171173
C313B2ED2B094696683AD0D1 /* IDReassociationTests.swift */,
172174
FCB60FA98EE57D48527FB2EA /* MatchExpanderTests.swift */,
175+
97856E02AB8BDCBA8D68DF7E /* MatchManagerWindowPlacementTests.swift */,
173176
197269D9A01D91DFA8FDD98B /* MatchValidationTests.swift */,
174177
B14B1A11DF46F0BD7EB11BEB /* RoundTripPreservationTests.swift */,
175178
4FFDCF28DBEF79CDB9D20962 /* TriggerConflictTests.swift */,
@@ -404,6 +407,7 @@
404407
1EFA438CA5AC5BE32547A12E /* EspansoProcessManagerTests.swift in Sources */,
405408
91C4437F5DD9899E51510D52 /* IDReassociationTests.swift in Sources */,
406409
9BD8321A9E830D64DF820D8A /* MatchExpanderTests.swift in Sources */,
410+
905D298986BDFED83E222004 /* MatchManagerWindowPlacementTests.swift in Sources */,
407411
81D985B3200D4ED0CD6F10E5 /* MatchValidationTests.swift in Sources */,
408412
1CC6166FFF8C6E24CC4B4AB9 /* RoundTripPreservationTests.swift in Sources */,
409413
DEEE48DB738AA4967926024C /* TriggerConflictTests.swift in Sources */,

macspanso/App/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>1.4.0</string>
20+
<string>1.4.1</string>
2121
<key>CFBundleVersion</key>
22-
<string>6</string>
22+
<string>7</string>
2323
<key>LSUIElement</key>
2424
<true/>
2525
<key>NSHumanReadableCopyright</key>

macspanso/MenuBar/MenuBarController.swift

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -224,16 +224,23 @@ final class MenuBarController: NSObject, NSMenuDelegate {
224224
)
225225
windowController = wc
226226
}
227-
if #available(macOS 14.0, *) {
228-
NSApp.activate()
229-
} else {
230-
NSApp.activate(ignoringOtherApps: true)
231-
}
227+
// Deliberately the deprecated explicit form, not the no-argument
228+
// NSApp.activate() added in macOS 14: for an accessory (LSUIElement) app
229+
// the cooperative no-arg variant can silently no-op when another app
230+
// won't yield activation — which is common right after a display change,
231+
// leaving the window ordered front but never brought forward.
232+
NSApp.activate(ignoringOtherApps: true)
232233
if let window = windowController?.window {
233-
// Display changes (docking, disconnected monitors) can leave the window
234-
// at coordinates no current screen covers — recenter if so.
235-
let onScreen = NSScreen.screens.contains { $0.frame.intersects(window.frame) }
236-
if !onScreen { window.center() }
234+
// Display changes (undocking, disconnected monitors) can leave the
235+
// window straddling a screen edge or fully off-screen. Recenter
236+
// unless a real fraction of it is reachable on a current display —
237+
// a bare `intersects` check passes on a 1px sliver and the window
238+
// looks like it never opened.
239+
let visibleFrames = NSScreen.screens.map { $0.visibleFrame }
240+
if MatchManagerWindowController.placement(forFrame: window.frame,
241+
visibleFrames: visibleFrames) == .recenter {
242+
window.center()
243+
}
237244
window.makeKeyAndOrderFront(nil)
238245
}
239246
switch focus {

macspanso/Views/MatchManagerWindow.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ final class MatchManagerWindowController: NSWindowController {
4040

4141
required init?(coder: NSCoder) { fatalError("not used") }
4242

43+
enum WindowPlacement: Equatable { case keep, recenter }
44+
45+
/// Decide whether a window at `frame` is reachable on the current displays,
46+
/// or should be recentered. Pure so it can be unit-tested without real
47+
/// screens.
48+
///
49+
/// `frame.intersects(screen)` is too weak: it's true for even a 1px overlap,
50+
/// so a window left straddling a screen edge after undocking passes it and
51+
/// gets ordered front effectively off-screen. Instead we require at least
52+
/// `minVisibleFraction` of the window's area to fall within a single screen's
53+
/// visible frame; otherwise the user can't reach it and we recenter.
54+
static func placement(forFrame frame: CGRect,
55+
visibleFrames: [CGRect],
56+
minVisibleFraction: CGFloat = 0.5) -> WindowPlacement {
57+
let windowArea = frame.width * frame.height
58+
guard windowArea > 0 else { return .recenter }
59+
let bestOverlapArea = visibleFrames.reduce(CGFloat(0)) { best, screen in
60+
let overlap = screen.intersection(frame)
61+
guard !overlap.isNull else { return best }
62+
return max(best, overlap.width * overlap.height)
63+
}
64+
return bestOverlapArea >= windowArea * minVisibleFraction ? .keep : .recenter
65+
}
66+
4367
func focusNewMatch() {
4468
postDelayed(.focusNewMatch)
4569
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// macspansoTests/MatchManagerWindowPlacementTests.swift
2+
import XCTest
3+
@testable import macspanso
4+
5+
/// The menu-bar action recenters the Match Manager window when a display change
6+
/// (undocking, switching from an external monitor to the internal one) has left
7+
/// it where the user can't reach it. The old guard used `frame.intersects`,
8+
/// which is true for even a 1px overlap — so a window left straddling a screen
9+
/// edge passed the check and was ordered front effectively off-screen, looking
10+
/// like "the window didn't open". These tests pin the stronger contract: a
11+
/// window only counts as visible when a real fraction of it is on a screen.
12+
final class MatchManagerWindowPlacementTests: XCTestCase {
13+
14+
/// 1440×900 internal display; menu bar eats the top 23pt, so visibleFrame
15+
/// height is 877. This is the screen that remains after undocking.
16+
private let internalVisibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 877)
17+
18+
/// After undocking, macOS can leave the window straddling the left edge with
19+
/// only a thin sliver on the remaining display. `intersects` would say "on
20+
/// screen"; the user sees nothing usable. This must recenter.
21+
func testRecentersWindowStraddlingScreenEdgeWithOnlyASliverVisible() {
22+
// Window spans x:-1100...100, so only 100pt of its 1200pt width overlaps.
23+
let frame = CGRect(x: -1100, y: 400, width: 1200, height: 820)
24+
25+
let placement = MatchManagerWindowController.placement(
26+
forFrame: frame, visibleFrames: [internalVisibleFrame])
27+
28+
XCTAssertEqual(placement, .recenter)
29+
}
30+
31+
/// A window comfortably within the remaining display must be left alone —
32+
/// recentering a perfectly-visible window would be its own annoying bug.
33+
func testKeepsWindowComfortablyOnScreen() {
34+
let frame = CGRect(x: 120, y: 40, width: 1200, height: 820)
35+
36+
let placement = MatchManagerWindowController.placement(
37+
forFrame: frame, visibleFrames: [internalVisibleFrame])
38+
39+
XCTAssertEqual(placement, .keep)
40+
}
41+
42+
/// The external monitor was to the left at negative coordinates; once it's
43+
/// gone, the window's frame intersects no remaining screen at all.
44+
func testRecentersWindowFullyOffScreen() {
45+
let frame = CGRect(x: -2000, y: 100, width: 1200, height: 820)
46+
47+
let placement = MatchManagerWindowController.placement(
48+
forFrame: frame, visibleFrames: [internalVisibleFrame])
49+
50+
XCTAssertEqual(placement, .recenter)
51+
}
52+
}

project.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ targets:
2727
LSUIElement: true
2828
CFBundleName: macspanso
2929
CFBundleDisplayName: macspanso
30-
CFBundleShortVersionString: "1.4.0"
31-
CFBundleVersion: "6"
30+
CFBundleShortVersionString: "1.4.1"
31+
CFBundleVersion: "7"
3232
NSHumanReadableCopyright: "Copyright © 2026 Macspanso"
3333
dependencies:
3434
- package: Yams

0 commit comments

Comments
 (0)