Files
endorphin-tessera-gui/src/shell/native_chrome.rs
T

128 lines
7.2 KiB
Rust

//! macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。
//! 仅在 macOS 编译;Objective-C 交互局限在原生窗口层,不参与 Endor 布局与事件分发。
//!
//! Keep AppKit's real window buttons aligned with the custom tab strip.
//! app::create_native/redraw 负责在事件循环线程调用对齐,app::window_event 的标题空白区按下路径调用双击处理。
//! 两个入口都只临时借用 winit 持有的 NSView/NSWindow;本模块不保存原生指针,也不向 VM 发送伪造标题栏事件。
use objc2_app_kit::{NSApplication, NSView, NSWindowButton, NSWindowStyleMask};
use objc2_foundation::{MainThreadMarker, NSPoint, NSString, NSUserDefaults};
use winit::{
raw_window_handle::{HasWindowHandle, RawWindowHandle},
window::Window,
};
/// The custom draggable row does not receive AppKit's titlebar double-click
/// action automatically. Use the native click count and native window actions.
/// 读取系统偏好的标题栏双击行为,委托原生窗口执行相应动作。
// shell/app 在自绘标题控制区左键按下时调用;true 表示本次双击已处理,调用方不再开始普通窗口拖动。
pub fn titlebar_double_click(window: &Window) -> bool {
// AppKit 必须在主线程调用;不在主线程、无有效句柄或句柄非 AppKit 时返回未处理。
let Some(main_thread) = MainThreadMarker::new() else {
return false;
};
let Ok(handle) = window.window_handle() else {
// 借用句柄失败表示当前无法可靠访问原生视图,不把它当成一次已处理的双击吞掉。
return false;
};
let RawWindowHandle::AppKit(handle) = handle.as_raw() else {
// 只允许 AppKit 句柄进入下面 NSView 转型,其他平台句柄不具有这些 Objective-C 对象前提。
return false;
};
// SAFETY: called on the event-loop thread during a left-button press, with
// a live winit-owned NSView. No native object outlives the Window borrow.
unsafe {
// 从当前 AppKit 事件读取原生 clickCount;无事件就不猜测双击,也不执行窗口动作。
let Some(event) = NSApplication::sharedApplication(main_thread).currentEvent() else {
return false;
};
// 依赖系统原生连击计数,不自行推算两次输入时间,保持用户设置与系统行为一致。
if event.clickCount() != 2 {
return false;
}
// 借用 winit 提供的活动 NSView,再取得所属 NSWindow;不创建窗口或把原生指针保存到外部状态。
let view = &*handle.ns_view.as_ptr().cast::<NSView>();
let Some(native) = view.window() else {
return false;
};
// 全屏时把双击视作已处理,但不执行缩放/最小化,避免自绘标题行改变全屏状态。
if native.styleMask().contains(NSWindowStyleMask::FullScreen) {
return true;
}
// 读取系统标题栏双击设置,字符串转成 Rust 自有值后按约定选原生动作。
let action = NSUserDefaults::standardUserDefaults()
.stringForKey(&NSString::from_str("AppleActionOnDoubleClick"))
.map(|value| value.to_string());
// 按系统偏好执行动作;缺省采用 Zoom,禁用或未知选项不动作,但仍返回已处理。
match action.as_deref() {
Some("Minimize") => native.performMiniaturize(None),
None | Some("Maximize" | "Fill") => native.performZoom(None),
// Respect disabled/unrecognized system actions without moving it.
_ => {}
}
}
true
}
/// Called only on winit's main thread, after native creation and before drawing.
/// AppKit can restore its default button positions when laying out the title bar.
/// 调整真实 macOS 窗口三键的位置,与全尺寸内容和侧栏控制区对齐。
// shell/app 创建窗口后和绘制前调用;主线程调用由外层保证,此函数本身没有创建 MainThreadMarker 检查。
pub fn align_controls(window: &Window) {
// 取得当前 Window 的句柄,失败或非 AppKit 平台表示无法调整,返回不改变现有原生布局。
let Ok(handle) = window.window_handle() else {
return;
};
let RawWindowHandle::AppKit(handle) = handle.as_raw() else {
return;
};
// SAFETY: winit owns this NSView for the lifetime of the borrowed Window.
// All AppKit access is on the event-loop thread; no native pointers escape.
unsafe {
// 安全前提由调用方保证:事件循环主线程、仍活着的 Window 借用,以及 winit 提供的有效 NSView;这里只短期访问。
let view = &*handle.ns_view.as_ptr().cast::<NSView>();
let Some(native) = view.window() else {
// 原生视图尚未关联窗口时无需移动控制按钮,不创建替代 NSWindow 来补齐。
return;
};
// Fullscreen traffic lights belong to AppKit's separate floating toolbar.
if native.styleMask().contains(NSWindowStyleMask::FullScreen) {
// 全屏按钮由 AppKit 独立工具栏管理,内容视图坐标不适合作为它们的位置来源。
return;
}
// 读取内容视图边界,用与侧栏相同的 HEADER 高度确定标题行中心。
let bounds = view.bounds();
let center = f64::from(crate::sidebar::HEADER) / 2.0;
// AppKit 视图可能以左下或左上为原点;先在内容视图内算标题行中心,再转到按钮父视图。
let content_y = if view.isFlipped() {
bounds.origin.y + center
} else {
bounds.origin.y + bounds.size.height - center
};
// 只处理关闭、最小化和缩放三个真实系统按钮,不创建伪按钮代替原生交互。
for kind in [
NSWindowButton::NSWindowCloseButton,
NSWindowButton::NSWindowMiniaturizeButton,
NSWindowButton::NSWindowZoomButton,
] {
// 系统样式可能不提供某个按钮,缺失时跳过该项,其他按钮仍继续对齐。
let Some(button) = native.standardWindowButton(kind) else {
continue;
};
// 需要按钮父视图做坐标转换;没有父视图时不能直接写内容视图坐标。
let Some(parent) = button.superview() else {
continue;
};
// 按钮可能位于不同 AppKit 容器,必须使用 convertPoint 而不能直接赋内容视图坐标。
let target = parent.convertPoint_fromView(NSPoint::new(0.0, content_y), Some(view));
// 保留当前大小与横坐标,仅把目标中心减半高换算成按钮纵向原点。
let frame = button.frame();
let y = target.y - frame.size.height / 2.0;
// Preserve native button size, horizontal spacing and behavior.
// 只改变足够明显的纵向偏差,保留系统按钮横向间距、尺寸与原生动作,避免反复微调布局。
if (frame.origin.y - y).abs() > 0.01 {
button.setFrameOrigin(NSPoint::new(frame.origin.x, y));
}
}
}
}