Rust · 12598 bytes Raw Blame History
1 //! NSWindow-backed overlay border.
2 //!
3 //! Replaces the SLS-only window approach. The reason is screenshot
4 //! exclusion on macOS Tahoe: `screencaptureui` enumerates windows via
5 //! `_SLSCopyWindowsWithOptionsAndTagsAndSpaceOptions` +
6 //! `_CGSGetWindowTags` and ignores the sharing-state of raw SLS-only
7 //! windows. NSWindow.sharingType = .none is the only documented and
8 //! verified-honored exclusion mechanism (verified empirically on Tahoe
9 //! with `screencapture -l <wid>`: SLS overlays capture, NSWindows with
10 //! `.none` sharingType return "could not create image from window").
11 //!
12 //! We use a CAShapeLayer for the rounded-rect border so updates stay
13 //! declarative — no NSView subclassing required.
14
15 use objc2::rc::Retained;
16 use objc2::runtime::AnyObject;
17 use objc2::{MainThreadMarker, MainThreadOnly, msg_send};
18 use objc2_app_kit::{
19 NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSScreen, NSWindow,
20 NSWindowCollectionBehavior, NSWindowOrderingMode, NSWindowSharingType, NSWindowStyleMask,
21 };
22 use objc2_core_foundation::{CGPoint, CGRect, CGSize};
23 use objc2_quartz_core::{CALayer, CAShapeLayer};
24 use std::ptr;
25
26 const NS_FLOATING_WINDOW_LEVEL: isize = 3;
27
28 /// Top-left Y in CG global coordinates becomes bottom-left Y in Cocoa
29 /// global coordinates by subtracting from the primary screen height.
30 ///
31 /// We use the main CGDisplay's bounds rather than `NSScreen.screens`
32 /// because NSScreen caches and only refreshes on certain notifications
33 /// — when a monitor is plugged or unplugged, NSScreen.screens can
34 /// return stale primary-height values, causing every cocoa Y on the
35 /// new layout to be off by the difference. CGDisplayBounds reflects
36 /// the current state immediately.
37 fn primary_screen_height() -> f64 {
38 let main_id = objc2_core_graphics::CGMainDisplayID();
39 objc2_core_graphics::CGDisplayBounds(main_id).size.height
40 }
41
42 fn cg_to_cocoa_frame(cg: CGRect, _mtm: MainThreadMarker) -> CGRect {
43 let primary_height = primary_screen_height();
44 let cocoa_y = primary_height - cg.origin.y - cg.size.height;
45 CGRect::new(
46 CGPoint::new(cg.origin.x, cocoa_y),
47 CGSize::new(cg.size.width, cg.size.height),
48 )
49 }
50
51 /// Log all NSScreens and which one we'll treat as primary. Helps diagnose
52 /// multi-monitor coordinate issues.
53 pub fn log_screens(mtm: MainThreadMarker) {
54 let screens = NSScreen::screens(mtm);
55 let primary_h = primary_screen_height();
56 let cg_main_bounds = {
57 let id = objc2_core_graphics::CGMainDisplayID();
58 objc2_core_graphics::CGDisplayBounds(id)
59 };
60 tracing::debug!(
61 cg_primary_height = primary_h,
62 cg_main_x = cg_main_bounds.origin.x,
63 cg_main_y = cg_main_bounds.origin.y,
64 cg_main_w = cg_main_bounds.size.width,
65 cg_main_h = cg_main_bounds.size.height,
66 nsscreen_count = screens.count(),
67 "screen layout"
68 );
69 for i in 0..screens.count() {
70 let s = screens.objectAtIndex(i);
71 let f = s.frame();
72 tracing::debug!(
73 index = i,
74 cocoa_x = f.origin.x,
75 cocoa_y = f.origin.y,
76 w = f.size.width,
77 h = f.size.height,
78 "nsscreen"
79 );
80 }
81 }
82
83 /// Initialize NSApplication. Must be called once from the main thread.
84 pub fn init_application() -> MainThreadMarker {
85 let mtm = MainThreadMarker::new().expect("init_application must run on the main thread");
86 let app = NSApplication::sharedApplication(mtm);
87 app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
88 mtm
89 }
90
91 /// One NSWindow + CAShapeLayer pair drawing a rounded-rect border.
92 ///
93 /// `bounds_cg_*` fields are the TARGET window's CG bounds (origin
94 /// top-left, Y-down) — same coordinate system the rest of ers uses.
95 pub struct OverlayWindow {
96 window: Retained<NSWindow>,
97 border_layer: Retained<CAShapeLayer>,
98 pub bounds_cg_x: f64,
99 pub bounds_cg_y: f64,
100 pub bounds_cg_w: f64,
101 pub bounds_cg_h: f64,
102 pub border_width: f64,
103 pub radius: f64,
104 mtm: MainThreadMarker,
105 }
106
107 impl OverlayWindow {
108 /// Create an NSWindow border overlay around the given target bounds.
109 /// Coords are in CG space (origin top-left, Y-down).
110 pub fn new(
111 bounds_cg_x: f64,
112 bounds_cg_y: f64,
113 bounds_cg_w: f64,
114 bounds_cg_h: f64,
115 border_width: f64,
116 radius: f64,
117 color: (f64, f64, f64, f64),
118 mtm: MainThreadMarker,
119 ) -> Option<Self> {
120 let outer_cg = CGRect::new(
121 CGPoint::new(bounds_cg_x - border_width, bounds_cg_y - border_width),
122 CGSize::new(
123 bounds_cg_w + 2.0 * border_width,
124 bounds_cg_h + 2.0 * border_width,
125 ),
126 );
127 let cocoa_frame = cg_to_cocoa_frame(outer_cg, mtm);
128
129 let style = NSWindowStyleMask::Borderless;
130 let window: Retained<NSWindow> = unsafe {
131 msg_send![
132 NSWindow::alloc(mtm),
133 initWithContentRect: cocoa_frame,
134 styleMask: style,
135 backing: NSBackingStoreType::Buffered,
136 defer: false
137 ]
138 };
139 window.setOpaque(false);
140 window.setHasShadow(false);
141 window.setIgnoresMouseEvents(true);
142 window.setLevel(NS_FLOATING_WINDOW_LEVEL);
143 unsafe { window.setReleasedWhenClosed(false) };
144 window.setSharingType(NSWindowSharingType::None);
145 // Do NOT set CanJoinAllSpaces: that would draw the overlay on
146 // every macOS space simultaneously. tarmac's workspaces are
147 // not macOS spaces, but if the user has both, leaking onto
148 // every space looks like a "stuck border" bug.
149 window.setCollectionBehavior(
150 NSWindowCollectionBehavior::Stationary
151 | NSWindowCollectionBehavior::IgnoresCycle
152 | NSWindowCollectionBehavior::FullScreenAuxiliary,
153 );
154 // Clear background.
155 let clear = NSColor::clearColor();
156 window.setBackgroundColor(Some(&clear));
157
158 let content_view = window.contentView()?;
159 content_view.setWantsLayer(true);
160 let host_layer: Retained<CALayer> = unsafe {
161 let layer: Option<Retained<CALayer>> = msg_send![&*content_view, layer];
162 layer?
163 };
164
165 let border_layer = CAShapeLayer::new();
166 let path_rect = inset_for_stroke(outer_cg.size, border_width);
167 unsafe {
168 let path = objc2_core_graphics::CGPath::with_rounded_rect(
169 path_rect, radius, radius, ptr::null(),
170 );
171 let path_ref: *mut AnyObject =
172 objc2_core_foundation::CFRetained::as_ptr(&path).as_ptr() as *mut AnyObject;
173 let _: () = msg_send![&*border_layer, setPath: path_ref];
174
175 let _: () = msg_send![&*border_layer, setFillColor: ptr::null::<AnyObject>()];
176 let stroke = make_cgcolor(color, mtm);
177 let stroke_ref: *mut AnyObject =
178 objc2_core_foundation::CFRetained::as_ptr(&stroke).as_ptr() as *mut AnyObject;
179 let _: () = msg_send![&*border_layer, setStrokeColor: stroke_ref];
180 border_layer.setLineWidth(border_width);
181 border_layer.setFrame(CGRect::new(
182 CGPoint::new(0.0, 0.0),
183 CGSize::new(outer_cg.size.width, outer_cg.size.height),
184 ));
185 host_layer.addSublayer(&border_layer);
186 }
187
188 window.orderFrontRegardless();
189
190 Some(OverlayWindow {
191 window,
192 border_layer,
193 bounds_cg_x,
194 bounds_cg_y,
195 bounds_cg_w,
196 bounds_cg_h,
197 border_width,
198 radius,
199 mtm,
200 })
201 }
202
203 /// NSWindow's windowNumber, usable as a wid for tracking.
204 pub fn wid(&self) -> u32 {
205 self.window.windowNumber() as u32
206 }
207
208 pub fn set_bounds(&mut self, x: f64, y: f64, w: f64, h: f64) {
209 let outer_cg = CGRect::new(
210 CGPoint::new(x - self.border_width, y - self.border_width),
211 CGSize::new(w + 2.0 * self.border_width, h + 2.0 * self.border_width),
212 );
213 let cocoa_frame = cg_to_cocoa_frame(outer_cg, self.mtm);
214 self.window.setFrame_display(cocoa_frame, true);
215 let actual = self.window.frame();
216 let placed_correctly = (actual.origin.x - cocoa_frame.origin.x).abs() < 0.5
217 && (actual.origin.y - cocoa_frame.origin.y).abs() < 0.5;
218 if !placed_correctly {
219 tracing::warn!(
220 requested_x = cocoa_frame.origin.x,
221 requested_y = cocoa_frame.origin.y,
222 actual_x = actual.origin.x,
223 actual_y = actual.origin.y,
224 "NSWindow rejected setFrame placement"
225 );
226 }
227 // Update the border path to match new size.
228 unsafe {
229 let path = objc2_core_graphics::CGPath::with_rounded_rect(
230 inset_for_stroke(outer_cg.size, self.border_width),
231 self.radius,
232 self.radius,
233 ptr::null(),
234 );
235 let path_ref: *mut AnyObject =
236 objc2_core_foundation::CFRetained::as_ptr(&path).as_ptr() as *mut AnyObject;
237 let _: () = msg_send![&*self.border_layer, setPath: path_ref];
238 self.border_layer.setFrame(CGRect::new(
239 CGPoint::new(0.0, 0.0),
240 CGSize::new(outer_cg.size.width, outer_cg.size.height),
241 ));
242 }
243 self.bounds_cg_x = x;
244 self.bounds_cg_y = y;
245 self.bounds_cg_w = w;
246 self.bounds_cg_h = h;
247 }
248
249 /// Re-apply just the CAShapeLayer's frame and path to match the
250 /// current stored bounds. Cheap — no NSWindow setFrame. Useful when
251 /// macOS resets layer state during display sleep/wake but the
252 /// NSWindow's frame survives (in which case sync_overlay won't see
253 /// any CG bounds change and won't re-apply state on its own).
254 pub fn reapply_layer(&self) {
255 let outer_w = self.bounds_cg_w + 2.0 * self.border_width;
256 let outer_h = self.bounds_cg_h + 2.0 * self.border_width;
257 let outer_size = CGSize::new(outer_w, outer_h);
258 unsafe {
259 let path = objc2_core_graphics::CGPath::with_rounded_rect(
260 inset_for_stroke(outer_size, self.border_width),
261 self.radius,
262 self.radius,
263 ptr::null(),
264 );
265 let path_ref: *mut AnyObject =
266 objc2_core_foundation::CFRetained::as_ptr(&path).as_ptr() as *mut AnyObject;
267 let _: () = msg_send![&*self.border_layer, setPath: path_ref];
268 self.border_layer.setFrame(CGRect::new(
269 CGPoint::new(0.0, 0.0),
270 CGSize::new(outer_w, outer_h),
271 ));
272 }
273 }
274
275 pub fn set_color(&self, color: (f64, f64, f64, f64)) {
276 unsafe {
277 let stroke = make_cgcolor(color, self.mtm);
278 let stroke_ref: *mut AnyObject =
279 objc2_core_foundation::CFRetained::as_ptr(&stroke).as_ptr() as *mut AnyObject;
280 let _: () = msg_send![&*self.border_layer, setStrokeColor: stroke_ref];
281 }
282 }
283
284 pub fn order_above(&self, target_wid: u32) {
285 self.window
286 .orderWindow_relativeTo(NSWindowOrderingMode::Above, target_wid as isize);
287 }
288
289 pub fn order_out(&self) {
290 self.window.orderOut(None);
291 }
292 }
293
294 impl Drop for OverlayWindow {
295 fn drop(&mut self) {
296 // orderOut first so the visual disappears synchronously;
297 // close() afterward releases the window. Without orderOut a
298 // closed-but-still-onscreen window can briefly linger on
299 // Tahoe before Retained drops the last ref.
300 self.window.orderOut(None);
301 self.window.close();
302 }
303 }
304
305 fn inset_for_stroke(size: CGSize, border_width: f64) -> CGRect {
306 // CAShapeLayer strokes centered on the path. To get an exactly
307 // border_width-thick visible ring sitting inside the layer bounds,
308 // inset the path by half the line width and stroke at line_width
309 // = border_width.
310 let half = border_width / 2.0;
311 CGRect::new(
312 CGPoint::new(half, half),
313 CGSize::new(
314 (size.width - 2.0 * half).max(0.0),
315 (size.height - 2.0 * half).max(0.0),
316 ),
317 )
318 }
319
320 fn make_cgcolor(
321 rgba: (f64, f64, f64, f64),
322 _mtm: MainThreadMarker,
323 ) -> objc2_core_foundation::CFRetained<objc2_core_graphics::CGColor> {
324 objc2_core_graphics::CGColor::new_srgb(rgba.0, rgba.1, rgba.2, rgba.3)
325 }
326