Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"globs": ["**/*.md", "!node_modules", "!target", "!app/node_modules", "!THIRD-PARTY-BUNDLED.md", "!THIRD-PARTY-LICENSES.md"],
"globs": ["**/*.md", "!node_modules", "!target", "!app/node_modules", "!vendor/softbuffer", "!THIRD-PARTY-BUNDLED.md", "!THIRD-PARTY-LICENSES.md"],
"config": {
"default": true,
"MD013": false,
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "app/src-tauri"]
exclude = ["vendor"]

[workspace.package]
version = "2.0.0"
Expand Down Expand Up @@ -65,3 +66,7 @@ unsafe_code = "deny"

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }

[patch.crates-io]
# macOS drops the alpha of every pixel without it; vendor/README.md says why and when it goes.
softbuffer = { path = "vendor/softbuffer" }
3 changes: 2 additions & 1 deletion THIRD-PARTY-BUNDLED.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<!-- Written by `npm run notices`. Do not edit by hand. -->

LinkUnbound is GPL-3.0-only. The binary carries the work below, each under its own
licence. Nothing of it was copied into LinkUnbound's own source. The licence text
licence. Nothing of it was copied into LinkUnbound's own source, save the crates under
`vendor/`, kept as published with the one change `vendor/README.md` describes. The licence text
of every crate is in [THIRD-PARTY-LICENSES.md](https://github.com/rgdevment/LinkUnbound/blob/main/THIRD-PARTY-LICENSES.md),
also under About → Licence texts.

Expand Down
4 changes: 4 additions & 0 deletions crates/linkunbound-mac/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
"block2",
"objc2-uniform-type-identifiers",
"objc2-quartz-core",
"NSAccessibility",
"NSAppearance",
"NSApplication",
"NSBitmapImageRep",
"NSColor",
"NSEvent",
"NSGlassEffectView",
"NSGraphics",
"NSGraphicsContext",
"NSImage",
Expand All @@ -92,7 +94,9 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
"NSResponder",
"NSRunningApplication",
"NSScreen",
"NSUserInterfaceItemIdentification",
"NSView",
"NSVisualEffectView",
"NSWindow",
"NSWorkspace",
] }
195 changes: 195 additions & 0 deletions crates/linkunbound-mac/src/glass.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
use std::cell::Cell;
use std::ptr::NonNull;
use std::rc::Rc;

use block2::RcBlock;
use objc2::Message;
use objc2::rc::Retained;
use objc2::runtime::AnyClass;
use objc2_app_kit::{
NSAppearance, NSAppearanceCustomization, NSAppearanceNameAqua, NSAppearanceNameDarkAqua,
NSBackingStoreType, NSColor, NSGlassEffectView, NSUserInterfaceItemIdentification, NSView,
NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, NSVisualEffectView,
NSWindow, NSWindowDidMoveNotification, NSWindowDidResizeNotification, NSWindowOrderingMode,
NSWindowStyleMask, NSWorkspace,
};
use objc2_foundation::{MainThreadMarker, NSNotification, NSNotificationCenter, NSPoint, NSString};

use crate::native::window_of;

const GLASS: &str = "linkunbound-glass";

/// Asked by name: the class only exists from macOS 26, and naming it in code, a downcast
/// included, panics on the systems before it.
fn glass_exists() -> bool {
AnyClass::get(c"NSGlassEffectView").is_some()

Check warning on line 25 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace glass_exists -> bool with false

Check warning on line 25 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace glass_exists -> bool with true
}

#[must_use]
pub fn liquid_glass() -> bool {
let skipped =
cfg!(debug_assertions) && std::env::var_os("LINKUNBOUND_NO_LIQUID_GLASS").is_some();

Check warning on line 31 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace && with || in liquid_glass
!skipped && glass_exists()

Check warning on line 32 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

delete ! in liquid_glass

Check warning on line 32 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace && with || in liquid_glass

Check warning on line 32 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace liquid_glass -> bool with false

Check warning on line 32 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace liquid_glass -> bool with true
}

#[must_use]
pub fn reduces_transparency() -> bool {
NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceTransparency()

Check warning on line 37 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace reduces_transparency -> bool with false

Check warning on line 37 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace reduces_transparency -> bool with true
}

fn material(mtm: MainThreadMarker) -> Retained<NSView> {
if liquid_glass() {
return Retained::into_super(NSGlassEffectView::new(mtm));
}
let vibrancy = NSVisualEffectView::new(mtm);
// Popover draws close to solid from macOS 26; menus stay see-through on every version.
vibrancy.setMaterial(NSVisualEffectMaterial::Menu);
vibrancy.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
vibrancy.setState(NSVisualEffectState::Active);
Retained::into_super(vibrancy)
}

fn round(material: &NSView, corner: f64) {
if glass_exists()
&& let Some(glass) = material.downcast_ref::<NSGlassEffectView>()
{
glass.setCornerRadius(corner);
return;
}
material.setWantsLayer(true);
if let Some(layer) = material.layer() {
layer.setCornerRadius(corner);
layer.setMasksToBounds(true);
}
}

#[allow(unsafe_code)]
fn tone(material: &NSView, dark: bool) {
// SAFETY: constant names AppKit exports.
let appearance = NSAppearance::appearanceNamed(unsafe {
if dark {
NSAppearanceNameDarkAqua
} else {
NSAppearanceNameAqua
}
});
material.setAppearance(appearance.as_deref());
}

fn is_glass(window: &NSWindow) -> bool {
let name = NSString::from_str(GLASS);
window
.contentView()
.and_then(|view| view.identifier())
.is_some_and(|id| *id == *name)

Check warning on line 84 in crates/linkunbound-mac/src/glass.rs

View workflow job for this annotation

GitHub Actions / mac / the lines this branch changed

Missed mutant

replace is_glass -> bool with true
}

/// The window holding the material behind the picker, if it wears one.
#[must_use]
pub(crate) fn glass_behind(picker: &NSWindow) -> Option<Retained<NSWindow>> {
picker.childWindows()?.iter().find(|child| is_glass(child))
}

#[allow(unsafe_code)]
fn glass_window(mtm: MainThreadMarker, picker: &NSWindow) -> Retained<NSWindow> {
// SAFETY: a fresh allocation, initialised once with constant arguments.
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc(),
picker.frame(),
NSWindowStyleMask::Borderless,
NSBackingStoreType::Buffered,
false,
)
};
// SAFETY: the window is owned here and never closed by AppKit on its own.
unsafe { window.setReleasedWhenClosed(false) };
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setHasShadow(false);
window.setIgnoresMouseEvents(true);
window.setHidesOnDeactivate(false);
window.setCollectionBehavior(picker.collectionBehavior());
let material = material(mtm);
material.setIdentifier(Some(&NSString::from_str(GLASS)));
window.setContentView(Some(&material));
// SAFETY: both windows live on the main thread; AppKit keeps the child with its parent.
unsafe { picker.addChildWindow_ordered(&window, NSWindowOrderingMode::Below) };
keep_with(picker, &window);
window
}

/// AppKit grows and shrinks a window from its bottom edge: the picker keeps its top instead, as it
/// does on Windows and as the bear leaning on it expects, and the glass takes its frame again.
#[allow(unsafe_code)]
fn keep_with(picker: &NSWindow, glass: &NSWindow) {
let top = Rc::new(Cell::new(None::<f64>));
let moved = {
let (picker, glass, top) = (picker.retain(), glass.retain(), Rc::clone(&top));
RcBlock::new(move |_: NonNull<NSNotification>| {
let frame = picker.frame();
top.set(Some(frame.origin.y + frame.size.height));
glass.setFrame_display(frame, true);
})
};
let resized = {
let (picker, glass) = (picker.retain(), glass.retain());
RcBlock::new(move |_: NonNull<NSNotification>| {
let frame = picker.frame();
if let Some(top) = top.get()
&& (frame.origin.y + frame.size.height - top).abs() > 0.5
{
picker.setFrameTopLeftPoint(NSPoint::new(frame.origin.x, top));
}
glass.setFrame_display(picker.frame(), true);
})
};
let centre = NSNotificationCenter::defaultCenter();
// SAFETY: AppKit's own names, posted on the main thread where the blocks run; the centre keeps
// both observers for as long as the picker, which lives as long as the app.
unsafe {
centre.addObserverForName_object_queue_usingBlock(
Some(NSWindowDidMoveNotification),
Some(picker),
None,
&moved,
);
centre.addObserverForName_object_queue_usingBlock(
Some(NSWindowDidResizeNotification),
Some(picker),
None,
&resized,
);
}
}

/// The painted sheet is back: the glass behind it would show in its shadow margin.
pub fn take_off_glass(view: isize) {
let Some(picker) = window_of(view) else {
return;
};
if let Some(glass) = glass_behind(&picker) {
picker.removeChildWindow(&glass);
glass.orderOut(None);
}
}

/// The material lives in a window of its own just behind the picker: AppKit only blends it with
/// what is behind when it is part of a window's content, and the picker's content is winit's.
pub fn wear_glass(view: isize, dark: bool, corner: f64) -> bool {
let Some(mtm) = MainThreadMarker::new() else {
return false;
};
let Some(picker) = window_of(view) else {
return false;
};
let glass = glass_behind(&picker).unwrap_or_else(|| glass_window(mtm, &picker));
glass.setLevel(picker.level());
glass.setFrame_display(picker.frame(), true);
let Some(material) = glass.contentView() else {
return false;
};
round(&material, corner);
tone(&material, dark);
true
}
11 changes: 8 additions & 3 deletions crates/linkunbound-mac/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ mod detect;
#[cfg(target_os = "macos")]
mod events;
#[cfg(target_os = "macos")]
mod glass;
#[cfg(target_os = "macos")]
mod icons;
#[cfg(target_os = "macos")]
mod legacy;
Expand All @@ -29,6 +31,8 @@ pub use detect::{app_named, bundle_id_named, installed_browsers};
#[cfg(target_os = "macos")]
pub use events::{Event, Listening, listen};
#[cfg(target_os = "macos")]
pub use glass::{liquid_glass, reduces_transparency, take_off_glass, wear_glass};
#[cfg(target_os = "macos")]
pub use icons::icon_for;
#[cfg(target_os = "macos")]
pub use legacy::{
Expand All @@ -38,8 +42,8 @@ pub use legacy::{
pub use native::{
Watching, copy_text, cursor, digit_behind, dress_window, front_application, front_is_ours,
is_in_front, keep_off_the_taskbar, let_whoever_opens_next_come_forward, menu_bar_is_light,
retire, shift_is_down, source_app, take_the_keyboard, translated, watch_activations,
windows_are_light, work_area_at,
retire, shift_is_down, source_app, take_the_keyboard, translated, tuck_beside,
watch_activations, windows_are_light, work_area_at,
};
#[cfg(target_os = "macos")]
pub use registration::{
Expand All @@ -53,8 +57,9 @@ mod audit {
use std::fs;
use std::path::Path;

const AUDITED: [&str; 5] = [
const AUDITED: [&str; 6] = [
"events.rs",
"glass.rs",
"icons.rs",
"native.rs",
"registration.rs",
Expand Down
35 changes: 33 additions & 2 deletions crates/linkunbound-mac/src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use objc2_app_kit::{
NSAppearance, NSAppearanceCustomization, NSAppearanceNameAqua, NSAppearanceNameDarkAqua,
NSApplication, NSApplicationActivationPolicy, NSColor, NSEvent, NSEventModifierFlags,
NSEventType, NSFloatingWindowLevel, NSPasteboard, NSPasteboardTypeString, NSRunningApplication,
NSScreen, NSView, NSWindow, NSWindowCollectionBehavior, NSWorkspace,
NSScreen, NSView, NSWindow, NSWindowCollectionBehavior, NSWindowOrderingMode, NSWorkspace,
NSWorkspaceDidActivateApplicationNotification,
};
use objc2_foundation::{
Expand Down Expand Up @@ -83,7 +83,7 @@ pub fn work_area_at(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
}

#[allow(unsafe_code)]
fn window_of(view: isize) -> Option<Retained<NSWindow>> {
pub(crate) fn window_of(view: isize) -> Option<Retained<NSWindow>> {
let view = view as *const NSView;
if view.is_null() {
return None;
Expand Down Expand Up @@ -114,6 +114,37 @@ pub fn keep_off_the_taskbar(view: isize, corner: f64) {
window.invalidateShadow();
}

/// The bear's windows ride with the picker: on its level, out of the way of clicks, the head
/// under it so the glass blurs it and the paws over its edge.
pub fn tuck_beside(view: isize, picker: isize, under: bool) {
let (Some(window), Some(picker)) = (window_of(view), window_of(picker)) else {
return;
};
// Over the picker means a level above it: taking the keyboard brings the picker to the front
// of its own level again.
window.setLevel(picker.level() + isize::from(!under));
window.setCollectionBehavior(
NSWindowCollectionBehavior::CanJoinAllSpaces
| NSWindowCollectionBehavior::FullScreenAuxiliary,
);
window.setHidesOnDeactivate(false);
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setHasShadow(false);
window.setIgnoresMouseEvents(true);
let place = if under {
NSWindowOrderingMode::Below
} else {
NSWindowOrderingMode::Above
};
// Under the glass, not just the picker, so the material blurs the hidden half of the head.
let beside = under
.then(|| crate::glass::glass_behind(&picker))
.flatten()
.unwrap_or(picker);
window.orderWindow_relativeTo(place, beside.windowNumber());
}

pub fn take_the_keyboard(view: isize) {
let Some(mtm) = MainThreadMarker::new() else {
return;
Expand Down
Loading
Loading