Skip to main content

macos_liquid_glass/
lib.rs

1//! Liquid Glass windows on macOS, that follow the system Icon & widget style.
2//!
3//! macOS 26 introduced `NSGlassEffectView`, the material behind Liquid Glass.
4//! Putting a window on it is the easy part. The hard part — and what this crate
5//! is actually for — is making that window agree with
6//! **System Settings ▸ Appearance ▸ Icon & widget style**, the setting that
7//! restyles desktop widgets, and keeping it in agreement as the user changes it.
8//!
9//! That turns out to involve a preference key with nine tokens behind a
10//! four-option UI, a notification that no notification centre posts, and a
11//! key-value observer that must never read a preference from inside its own
12//! callback. Each of those is documented where it is handled, with the
13//! measurement that established it. `MEASUREMENTS.md` in the repository is the
14//! long form.
15//!
16//! # Features
17//!
18//! | feature | default | what it brings |
19//! |---|---|---|
20//! | `glass` | yes | the `glass::GlassSurface` wrapper and its availability guard |
21//! | `window` | yes | a transparent window hosting one glass surface, titled or borderless |
22//! | `icon-style` | yes | the Icon & widget style tracker — usable on its own |
23//! | `private-spi` | **no** | two undocumented selectors; see below |
24//!
25//! [`is_dark`] and [`accessibility`] are behind **no** feature: the first is the
26//! crate's only light/dark resolver and both halves need it, and the second is
27//! an obligation rather than an option.
28//!
29//! # Accessibility
30//!
31//! A translucent surface has to answer to **Reduce Transparency**. This crate
32//! reports that setting through [`accessibility`] and does **not** act on it for
33//! you — `NSGlassEffectView` has no opacity control, so honouring it means not
34//! using the material at all and substituting opaque content, which is the
35//! caller's to build. That module states the reasoning in full and shows the
36//! shape to write. Check it before you construct a surface.
37//!
38//! `private-spi` is off because App Store Review Guideline 2.5.1 is "Apps may
39//! only use public APIs", with no `respondsToSelector:` exemption — a crate
40//! reaching private selectors by default would hand every consumer a submission
41//! liability they never opted into. Without it `icon-style` still tracks the
42//! style; only `icon_style::WidgetStyle::tint` stops reporting a colour, because
43//! the theme colour has no public source.
44//!
45//! # Example
46//!
47//! Compiled as a doctest, so it cannot drift from the API the way a README
48//! snippet can.
49//!
50//! The example needs `window`, `glass` and `icon-style` together, so `cfg_attr`
51//! picks the opening fence — runnable when all three are on, `ignore` otherwise
52//! — which keeps `cargo test` green under every other feature set, including
53//! `default-features = false, features = ["icon-style"]`, the standalone-tracker
54//! configuration this crate advertises. Verify a change here with `cargo test`,
55//! not `cargo check`: `check` does not compile doctests.
56//!
57
58#![cfg_attr(
59    all(feature = "window", feature = "glass", feature = "icon-style"),
60    doc = "```no_run"
61)]
62#![cfg_attr(
63    not(all(feature = "window", feature = "glass", feature = "icon-style")),
64    doc = "```ignore"
65)]
66//! use macos_liquid_glass::glass::{GlassStyle, GlassSurface};
67//! use macos_liquid_glass::window::GlassWindow;
68//! use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize};
69//!
70//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
71//! let mtm = MainThreadMarker::new().expect("main thread");
72//! let size = NSSize::new(560.0, 360.0);
73//! let frame = NSRect::new(NSPoint::new(0.0, 0.0), size);
74//!
75//! let window = GlassWindow::new(mtm, size, "example");
76//! let glass = GlassSurface::new(mtm, frame, GlassStyle::Clear, 16.0)?;
77//! window.set_content_view(glass.view());
78//!
79//! // Track the Icon & widget style for as long as the window lives. The
80//! // window owns the tracker, so there is nothing to keep hold of.
81//! window.follow_icon_style();
82//!
83//! window.show();
84//! # Ok(())
85//! # }
86//! ```
87//!
88//! # Driving your own drawing from the style
89//!
90//! `GlassWindow::follow_icon_style` sets the window's *appearance* and
91//! nothing else. To colour your own content from the style — a theme tint, a
92//! dimming layer, per-token drawing — observe it directly and keep the observer
93//! alive for as long as you want changes, because dropping it is what
94//! unregisters:
95//!
96//! ```ignore
97//! let _observer = StyleObserver::new(mtm, move |style| {
98//!     match style.token() { /* all nine are distinguishable here */ }
99//!     let tint = style.tint();
100//! });
101//! ```
102//!
103//! `icon-style` carries no dependency on `window`, so a consumer that already
104//! has an `NSWindow` takes the tracker by itself and applies the style however
105//! it draws:
106//!
107//! ```toml
108//! macos-liquid-glass = { version = "1.0.0-beta.1", default-features = false, features = ["icon-style"] }
109//! ```
110//!
111//! # Threads
112//!
113//! Everything here is main-thread-only and none of it is `Send` or `Sync` —
114//! `GlassWindow`, `GlassSurface` and `StyleObserver` all hold objects that are
115//! `MainThreadOnly`, so the compiler will not let one cross a thread boundary.
116//! That is enforcement, not convention: you cannot construct any of them
117//! without a `MainThreadMarker`, and you cannot move one to a thread that has
118//! no such marker.
119//!
120//! The observer's callback is delivered on the main thread too, so it may touch
121//! AppKit freely. It is `Fn` rather than `FnMut` because it can re-enter — see
122//! `icon_style::StyleObserver::new`.
123//!
124//! [`accessibility`] is the exception, and reads `NSWorkspace`, which is not
125//! main-thread-only.
126//!
127//! # Platform
128//!
129//! macOS only. On every other target this crate is empty rather than absent, so
130//! a cross-platform consumer can depend on it unconditionally and gate at the
131//! call site. The glass surface additionally requires macOS 26 at *runtime* —
132//! `NSGlassEffectView` does not exist before it — which is checked rather than
133//! assumed.
134
135#![cfg_attr(docsrs, feature(doc_cfg))]
136
137/// The objc2 crates this crate's own signatures are built from, re-exported at
138/// the exact versions it was compiled against.
139///
140/// Without these a consumer must declare `objc2-foundation` (and `-app-kit`,
141/// and `-core-foundation`) themselves and keep them unifying by hand. When they
142/// do not unify, cargo reports no version conflict — it compiles both copies and
143/// rustc says `expected MainThreadMarker, found MainThreadMarker`, naming the
144/// same type twice. Re-exporting makes the right versions reachable by
145/// construction; declaring them directly still works and reads better.
146///
147/// # What this promises
148///
149/// These are pass-throughs so that versions unify, not a promise to freeze all
150/// of AppKit. Items reachable here that this crate's own signatures do not use
151/// carry objc2's stability guarantees, not this crate's.
152///
153/// The example below deliberately references no feature-gated item, so it holds
154/// under every feature set — including `default-features = false`, which is
155/// exactly the configuration a consumer taking the tracker alone uses.
156///
157/// ```
158/// # #[cfg(target_os = "macos")] {
159/// use macos_liquid_glass::objc2_app_kit::NSColor;
160/// use macos_liquid_glass::objc2_core_foundation::CGFloat;
161/// use macos_liquid_glass::objc2_foundation::{MainThreadMarker, NSSize};
162///
163/// let _: Option<MainThreadMarker> = MainThreadMarker::new();
164/// let _: NSSize = NSSize::new(560.0, 360.0);
165/// let _: Option<&NSColor> = None;
166/// let _: CGFloat = 16.0;
167/// # }
168/// ```
169#[cfg(target_os = "macos")]
170pub use {objc2, objc2_app_kit, objc2_core_foundation, objc2_foundation};
171
172#[cfg(target_os = "macos")]
173pub mod accessibility;
174#[cfg(all(target_os = "macos", feature = "drawable"))]
175pub mod drawable;
176#[cfg(all(target_os = "macos", feature = "glass"))]
177pub mod glass;
178#[cfg(all(target_os = "macos", feature = "icon-style"))]
179pub mod icon_style;
180#[cfg(target_os = "macos")]
181pub mod menu;
182#[cfg(all(target_os = "macos", feature = "window"))]
183pub mod window;
184#[cfg(all(target_os = "macos", feature = "drawable"))]
185pub use {objc2_io_surface, objc2_quartz_core};
186
187#[cfg(target_os = "macos")]
188use objc2_app_kit::{NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua};
189#[cfg(target_os = "macos")]
190use objc2_foundation::NSArray;
191
192/// Whether an appearance resolves to Dark.
193///
194/// Deliberately **not** behind a feature: this is the crate's only light/dark
195/// resolver, and a consumer taking `icon-style` alone needs it as much as one
196/// taking `window`.
197///
198/// # Why not compare the name
199///
200/// `bestMatchFromAppearancesWithNames:` rather than `name() == DarkAqua`,
201/// because an effective appearance can be a **vibrant or accessibility
202/// variant** whose name is neither `Aqua` nor `DarkAqua`, and only asking for
203/// the best match of the two resolves those. Measured on macOS 27.0: for
204/// `NSAppearanceNameVibrantDark` a name comparison answers `false` — it calls a
205/// dark appearance light — while this answers `true`. That naive comparison is
206/// exactly what a consumer writes when this function is out of reach, which is
207/// why it is reachable.
208///
209/// # Which appearance to pass
210///
211/// The *ambient* one — normally your window's or view's `effectiveAppearance`.
212/// This takes it as an argument rather than reading `NSApp` because
213/// `NSApplication::sharedApplication` **creates** the application object as a
214/// side effect, which a library getter must never do, and because it would then
215/// need a `MainThreadMarker` purely to reach an appearance.
216#[cfg(target_os = "macos")]
217pub fn is_dark(ambient: &NSAppearance) -> bool {
218    let names = NSArray::from_slice(&[unsafe { NSAppearanceNameAqua }, unsafe {
219        NSAppearanceNameDarkAqua
220    }]);
221    match ambient.bestMatchFromAppearancesWithNames(&names) {
222        Some(best) => &*best == unsafe { NSAppearanceNameDarkAqua },
223        None => false,
224    }
225}
226
227// ── icons ───────────────────────────────────────────────────────────────────
228//
229// A consumer that `forbid`s `unsafe` — as a renderer that only wants a window
230// should — cannot call the objc2 image setters, which are `unsafe`. These wrap
231// the three an app needs: decode bytes, set the dock icon, make an image view.
232
233#[cfg(target_os = "macos")]
234use objc2::AllocAnyThread;
235#[cfg(target_os = "macos")]
236use objc2::rc::Retained;
237#[cfg(target_os = "macos")]
238use objc2_app_kit::{NSApplication, NSImage, NSImageScaling, NSImageView};
239#[cfg(target_os = "macos")]
240use objc2_foundation::{MainThreadMarker, NSData};
241
242/// Decode encoded image bytes (PNG, JPEG, …) into an `NSImage`, or `None` if
243/// the data is not a decodable image.
244#[cfg(target_os = "macos")]
245pub fn image_from_bytes(bytes: &[u8]) -> Option<Retained<NSImage>> {
246    let data = NSData::with_bytes(bytes);
247    NSImage::initWithData(NSImage::alloc(), &data)
248}
249
250/// The AppKit the process is running against, as `NSAppKitVersionNumber`
251/// -- the build number a launch measurement should be recorded beside.
252/// Reading it is also the one reference to an AppKit *symbol* a binary
253/// that reaches every class by name through the runtime has, which is
254/// what keeps the framework's load command when the link dead-strips the
255/// dylibs nothing references: `-needed_framework` only works ahead of the
256/// `-framework` the bindings emit, and a build script's link arguments
257/// come after it.
258#[cfg(target_os = "macos")]
259pub fn appkit_version() -> f64 {
260    // SAFETY: an extern static AppKit defines and initialises when it
261    // loads, read once and never written.
262    unsafe { objc2_app_kit::NSAppKitVersionNumber }
263}
264
265/// Set the application's dock and Cmd-Tab icon.
266#[cfg(target_os = "macos")]
267pub fn set_application_icon(app: &NSApplication, image: &NSImage) {
268    // SAFETY: AppKit copies the image; setting the app icon has no aliasing or
269    // lifetime hazard.
270    unsafe { app.setApplicationIconImage(Some(image)) };
271}
272
273/// An `NSImageView` showing `image`, scaled to fit — for a titlebar strip or
274/// anywhere the caller then positions with `setFrame`/autoresizing.
275#[cfg(target_os = "macos")]
276pub fn icon_view(mtm: MainThreadMarker, image: &NSImage) -> Retained<NSImageView> {
277    let view = NSImageView::new(mtm);
278    view.setImage(Some(image));
279    view.setImageScaling(NSImageScaling::ScaleProportionallyUpOrDown);
280    view
281}
282
283#[cfg(all(test, target_os = "macos"))]
284mod tests {
285    use objc2_app_kit::NSAppearanceNameVibrantDark;
286
287    use super::*;
288
289    /// Pins the `bestMatch` algorithm against the name comparison a consumer
290    /// reaches for when this resolver is unavailable.
291    ///
292    /// Kills the mutation "compare `name()` to `DarkAqua`": `VibrantDark` is a
293    /// dark appearance whose name is neither Aqua nor DarkAqua, so the naive
294    /// version answers `false` here and this test fails.
295    #[test]
296    fn vibrant_dark_resolves_dark_even_though_its_name_is_neither() {
297        let vibrant = NSAppearance::appearanceNamed(unsafe { NSAppearanceNameVibrantDark })
298            .expect("VibrantDark is a stock appearance");
299        assert_ne!(
300            &*vibrant.name(),
301            unsafe { NSAppearanceNameDarkAqua },
302            "precondition: VibrantDark's name is not DarkAqua, or this proves nothing"
303        );
304        assert!(is_dark(&vibrant), "VibrantDark is a dark appearance");
305    }
306
307    #[test]
308    fn the_two_stock_appearances_resolve_to_themselves() {
309        for (name, want) in [
310            (unsafe { NSAppearanceNameAqua }, false),
311            (unsafe { NSAppearanceNameDarkAqua }, true),
312        ] {
313            let appearance = NSAppearance::appearanceNamed(name).expect("stock appearance");
314            assert_eq!(is_dark(&appearance), want, "{name:?}");
315        }
316    }
317}