use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
use objc2::{define_class, msg_send, sel, MainThreadOnly};
use objc2_app_kit::{
NSButton, NSView, NSViewFrameDidChangeNotification, NSWindow, NSWindowButton,
NSWindowDidUpdateNotification,
};
use objc2_foundation::{
ns_string, MainThreadMarker, NSDictionary, NSKeyValueChangeKey, NSKeyValueObservingOptions,
NSNotification, NSNotificationCenter, NSObjectNSKeyValueObserverRegistration, NSObjectProtocol,
NSPoint, NSRect, NSSize, NSString,
};
use std::cell::RefCell;
use std::collections::HashSet;
use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Copy)]
struct Inset {
pad_x: f64,
pad_y: f64,
spacing: Option<f64>,
}
static CONFIG: Mutex<Option<Inset>> = Mutex::new(None);
struct DefaultSnapshot {
container_height: f64,
button_origins: [NSPoint; 3],
}
static DEFAULTS: OnceLock<DefaultSnapshot> = OnceLock::new();
fn standard_buttons(window: &NSWindow) -> Option<[Retained<NSButton>; 3]> {
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
Some([close, minimize, zoom])
}
fn container_of(button: &NSButton) -> Option<Retained<NSView>> {
unsafe { button.superview().and_then(|view| view.superview()) }
}
fn set_origin_if_needed(button: &NSButton, target: NSPoint) {
let current = button.frame().origin;
if (current.x - target.x).abs() > 0.5 || (current.y - target.y).abs() > 0.5 {
button.setFrameOrigin(target);
}
}
fn capture_defaults(buttons: &[Retained<NSButton>; 3], container: &NSView) -> DefaultSnapshot {
DefaultSnapshot {
container_height: container.frame().size.height,
button_origins: [
buttons[0].frame().origin,
buttons[1].frame().origin,
buttons[2].frame().origin,
],
}
}
fn reposition(ns_window: &NSWindow, inset: Inset) {
let Some(buttons) = standard_buttons(ns_window) else {
return;
};
let Some(container) = container_of(&buttons[0]) else {
return;
};
let snapshot = DEFAULTS.get_or_init(|| capture_defaults(&buttons, &container));
let button_height = buttons[0].frame().size.height;
let _ = container;
let button_y = snapshot.container_height - inset.pad_y - button_height;
let spacing = inset
.spacing
.unwrap_or(snapshot.button_origins[1].x - snapshot.button_origins[0].x);
for (i, button) in buttons.iter().enumerate() {
set_origin_if_needed(
button,
NSPoint {
x: inset.pad_x + (i as f64) * spacing,
y: button_y,
},
);
}
}
fn restore_default(ns_window: &NSWindow) {
let Some(snapshot) = DEFAULTS.get() else {
return;
};
let Some(buttons) = standard_buttons(ns_window) else {
return;
};
let Some(container) = container_of(&buttons[0]) else {
return;
};
let window_size = ns_window.frame().size;
container.setFrame(NSRect {
origin: NSPoint {
x: 0.0,
y: window_size.height - snapshot.container_height,
},
size: NSSize {
width: window_size.width,
height: snapshot.container_height,
},
});
for (i, button) in buttons.iter().enumerate() {
set_origin_if_needed(button, snapshot.button_origins[i]);
}
}
fn with_ns_window<F: FnOnce(&NSWindow)>(window: &tauri::WebviewWindow, f: F) {
let Ok(ns_window_ptr) = window.ns_window() else {
return;
};
if ns_window_ptr.is_null() {
return;
}
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
f(ns_window);
}
thread_local! {
static APPLYING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn apply(window: &tauri::WebviewWindow) {
if APPLYING.with(|f| f.get()) {
return;
}
APPLYING.with(|f| f.set(true));
let config = *CONFIG.lock().unwrap();
with_ns_window(window, |ns_window| {
install_observers(ns_window);
match config {
Some(inset) => reposition(ns_window, inset),
None => restore_default(ns_window),
}
});
APPLYING.with(|f| f.set(false));
}
static OBSERVER_APP: OnceLock<tauri::AppHandle> = OnceLock::new();
thread_local! {
static RELAYOUT_OBSERVER: RefCell<Option<Retained<TrafficLightObserver>>> =
const { RefCell::new(None) };
static OBSERVED_BUTTONS: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "TishTrafficLightObserver"]
#[thread_kind = MainThreadOnly]
#[ivars = ()]
struct TrafficLightObserver;
unsafe impl NSObjectProtocol for TrafficLightObserver {}
impl TrafficLightObserver {
#[unsafe(method(onWindowRelayout:))]
fn on_window_relayout(&self, _note: Option<&NSNotification>) {
reapply_all();
}
#[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
fn observe_value(
&self,
_key_path: Option<&NSString>,
_object: Option<&AnyObject>,
_change: Option<&NSDictionary<NSKeyValueChangeKey, AnyObject>>,
_context: *mut c_void,
) {
reapply_all();
}
}
);
fn reapply_all() {
let Some(app) = OBSERVER_APP.get() else {
return;
};
use tauri::Manager;
for (_, window) in app.webview_windows() {
apply(&window);
}
}
impl TrafficLightObserver {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = mtm.alloc().set_ivars(());
unsafe { msg_send![super(this), init] }
}
}
fn observer(mtm: MainThreadMarker) -> Retained<TrafficLightObserver> {
RELAYOUT_OBSERVER.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| TrafficLightObserver::new(mtm))
.clone()
})
}
fn install_observers(ns_window: &NSWindow) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if OBSERVER_APP.get().is_none() {
return;
}
let center = NSNotificationCenter::defaultCenter();
let observer = observer(mtm);
static WINDOW_OBSERVER_INSTALLED: OnceLock<()> = OnceLock::new();
if WINDOW_OBSERVER_INSTALLED.set(()).is_ok() {
unsafe {
center.addObserver_selector_name_object(
&observer,
sel!(onWindowRelayout:),
Some(NSWindowDidUpdateNotification),
None,
);
}
}
let Some(buttons) = standard_buttons(ns_window) else {
return;
};
for button in &buttons {
let key = Retained::as_ptr(button) as usize;
if OBSERVED_BUTTONS.with(|set| set.borrow().contains(&key)) {
continue;
}
button.setPostsFrameChangedNotifications(true);
unsafe {
center.addObserver_selector_name_object(
&observer,
sel!(onWindowRelayout:),
Some(NSViewFrameDidChangeNotification),
Some(button),
);
button.addObserver_forKeyPath_options_context(
&observer,
ns_string!("frame"),
NSKeyValueObservingOptions::empty(),
NonNull::dangling().as_ptr(),
);
}
OBSERVED_BUTTONS.with(|set| set.borrow_mut().insert(key));
}
}
pub fn init(app: &tauri::AppHandle) {
let _ = OBSERVER_APP.set(app.clone());
}
fn apply_all_scheduled(app: &tauri::AppHandle) {
for delay_ms in [0u64, 120, 300, 700, 1400] {
let handle = app.clone();
std::thread::spawn(move || {
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
let inner = handle.clone();
let _ = handle.run_on_main_thread(move || {
use tauri::Manager;
for (_, window) in inner.webview_windows() {
apply(&window);
}
});
});
}
}
pub fn set_inset(
app: &tauri::AppHandle,
pad_x: Option<f64>,
pad_y: Option<f64>,
spacing: Option<f64>,
) {
let config = match (pad_x, pad_y) {
(Some(x), Some(y)) => Some(Inset {
pad_x: x,
pad_y: y,
spacing,
}),
_ => None,
};
*CONFIG.lock().unwrap() = config;
apply_all_scheduled(app);
}
pub fn reapply(app: &tauri::AppHandle) {
apply_all_scheduled(app);
}