mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
Escape the page leaves unhandled reaches the window as cancelOperation:, and NSWindow leaves full screen on it, so pressing Escape once more after closing a popup dropped the IDE out of full screen. The IDE's windows now answer cancelOperation: with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves full screen. A page-level preventDefault was not used because several windows close on an otherwise unhandled Escape. Fixes #3104
222 lines
9.6 KiB
Rust
222 lines
9.6 KiB
Rust
//! macOS project tabs: native window tab groups behind the page's own tab bar.
|
||
use crate::windows::{Launch, WindowsState};
|
||
use ide_core::{IdeError, IdeResult};
|
||
use ide_workspace::window_groups::{TabbingPreference, should_group_new_project};
|
||
use objc2::{
|
||
MainThreadMarker,
|
||
rc::Retained,
|
||
runtime::{AnyClass, AnyObject, Imp, Sel},
|
||
};
|
||
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
|
||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||
|
||
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
|
||
fn read(mtm: MainThreadMarker) -> TabbingPreference {
|
||
match NSWindow::userTabbingPreference(mtm) {
|
||
NSWindowUserTabbingPreference::Always => TabbingPreference::Always,
|
||
NSWindowUserTabbingPreference::InFullScreen => TabbingPreference::InFullScreen,
|
||
_ => TabbingPreference::Manually,
|
||
}
|
||
}
|
||
if let Some(mtm) = MainThreadMarker::new() {
|
||
return Ok(read(mtm));
|
||
}
|
||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||
app.run_on_main_thread(move || {
|
||
let _ = tx.send(read(MainThreadMarker::new().expect("AppKit main thread")));
|
||
})
|
||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||
rx.recv().map_err(|e| IdeError::internal(e.to_string()))
|
||
}
|
||
|
||
/// Capture before building the new window, which takes focus itself.
|
||
pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Option<WebviewWindow>> {
|
||
let windows = app.webview_windows();
|
||
let active = windows
|
||
.values()
|
||
.find(|w| w.is_focused().unwrap_or(false))
|
||
.cloned()
|
||
.or_else(|| app.state::<WindowsState>().last_focused().and_then(|label| windows.get(&label).cloned()));
|
||
let active = active.and_then(|w| {
|
||
let label = crate::windows::auxiliary_parent(w.label()).unwrap_or(w.label());
|
||
windows.get(label).cloned()
|
||
});
|
||
let active = active.filter(|w| {
|
||
let host = app.state::<crate::state::AppState>();
|
||
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
|
||
});
|
||
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
|
||
let light_edit = launch.project.is_none() && launch.remote.is_none();
|
||
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
|
||
.then_some(active)
|
||
.flatten())
|
||
}
|
||
|
||
/// Runs `f` on the main thread before returning, so it takes effect before what the caller does next.
|
||
fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Send + 'static) -> tauri::Result<R> {
|
||
if MainThreadMarker::new().is_some() {
|
||
return Ok(f());
|
||
}
|
||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||
window.run_on_main_thread(move || {
|
||
let _ = tx.send(f());
|
||
})?;
|
||
rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage)
|
||
}
|
||
|
||
/// Project tabs are native window tabs, so a group shares one frame and enters full screen as one
|
||
/// window, with the system's window buttons on whichever tab is shown. The tab bar is the page's
|
||
/// own; AppKit's is hidden, and windows never join a tab group on their own.
|
||
pub fn prepare_window_tabbing(mtm: MainThreadMarker) {
|
||
NSWindow::setAllowsAutomaticWindowTabbing(false, mtm);
|
||
}
|
||
|
||
fn native(window: &WebviewWindow) -> Option<&'static NSWindow> {
|
||
let pointer = window.ns_window().ok()?;
|
||
Some(unsafe { &*pointer.cast::<NSWindow>() })
|
||
}
|
||
|
||
fn same_group(a: &NSWindow, b: &NSWindow) -> bool {
|
||
match (a.tabGroup(), b.tabGroup()) {
|
||
(Some(a), Some(b)) => Retained::as_ptr(&a) == Retained::as_ptr(&b),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Adds `members` to the existing anchor's native tab group and shows `selected`.
|
||
pub fn sync_tab_group(
|
||
members: Vec<WebviewWindow>,
|
||
anchor: &WebviewWindow,
|
||
selected: &WebviewWindow,
|
||
) -> tauri::Result<()> {
|
||
let anchor = anchor.clone();
|
||
let target = selected.clone();
|
||
on_main(selected, move || {
|
||
let Some(shown) = native(&target) else { return };
|
||
let Some(anchor_window) = native(&anchor) else { return };
|
||
// Joining into a new tab's desktop window would take the existing group out of full screen.
|
||
for member in members.iter().filter(|m| m.label() != anchor.label()) {
|
||
if let Some(window) = native(member)
|
||
&& !same_group(anchor_window, window)
|
||
{
|
||
anchor_window.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above);
|
||
}
|
||
}
|
||
if let Some(group) = shown.tabGroup() {
|
||
group.setSelectedWindow(Some(shown));
|
||
}
|
||
shown.makeKeyAndOrderFront(None);
|
||
for member in &members {
|
||
if let Some(window) = native(member) {
|
||
hide_tab_bar(window);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Takes `window` out of its tab group into a window of its own; in full screen, a Space of its own.
|
||
pub fn move_tab_to_new_window(window: &WebviewWindow) -> tauri::Result<()> {
|
||
let target = window.clone();
|
||
on_main(window, move || {
|
||
let Some(native) = native(&target) else { return };
|
||
if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) {
|
||
native.moveTabToNewWindow(None);
|
||
}
|
||
hide_tab_bar(native);
|
||
})
|
||
}
|
||
|
||
fn has_tab_bar(view: &objc2_app_kit::NSView) -> bool {
|
||
let class = view.class().name().to_string_lossy();
|
||
class.contains("TabBar") || view.subviews().iter().any(|v| has_tab_bar(&v))
|
||
}
|
||
|
||
/// Hides AppKit's tab bar, which a window shows while its group has more than one tab. It moves to
|
||
/// whichever tab is shown and comes back when the group changes, so this runs after each change.
|
||
pub fn hide_tab_bar(window: &NSWindow) {
|
||
for accessory in window.titlebarAccessoryViewControllers().iter() {
|
||
if !accessory.isHidden() && has_tab_bar(&accessory.view()) {
|
||
accessory.setHidden(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
|
||
|
||
/// `NSApplicationTerminateReply`.
|
||
const TERMINATE_CANCEL: usize = 0;
|
||
const TERMINATE_NOW: usize = 1;
|
||
|
||
/// The Dock's Quit, the system's app menu and a logout ask the application delegate whether it
|
||
/// may terminate; tao's delegate doesn't answer, so AppKit terminated at once, past the exit,
|
||
/// running-process and unsaved-changes prompts. Answer it: cancel, and quit like Cmd+Q does.
|
||
pub fn intercept_system_quit(app: &AppHandle) {
|
||
let _ = APP.set(app.clone());
|
||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||
let application = objc2_app_kit::NSApplication::sharedApplication(mtm);
|
||
unsafe {
|
||
let delegate: *mut AnyObject = objc2::msg_send![&*application, delegate];
|
||
if delegate.is_null() {
|
||
return;
|
||
}
|
||
let class = objc2::ffi::object_getClass(delegate.cast()) as *mut AnyClass;
|
||
let imp: Imp = std::mem::transmute(should_terminate as ShouldTerminate);
|
||
let added = objc2::ffi::class_addMethod(class, objc2::sel!(applicationShouldTerminate:), imp, c"Q@:@".as_ptr());
|
||
if !added.as_bool() {
|
||
tracing::warn!("could not handle applicationShouldTerminate:");
|
||
}
|
||
}
|
||
}
|
||
|
||
unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) -> usize {
|
||
let Some(app) = APP.get() else { return TERMINATE_NOW };
|
||
// Our own quit exits through the event loop; a system quit during it goes ahead.
|
||
if app.try_state::<WindowsState>().is_none_or(|w| w.quitting()) {
|
||
return TERMINATE_NOW;
|
||
}
|
||
crate::dispatch_menu_action(app, "app.quit");
|
||
TERMINATE_CANCEL
|
||
}
|
||
|
||
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
|
||
|
||
/// Escape the page leaves unhandled reaches the window as `cancelOperation:`, on which NSWindow
|
||
/// leaves full screen, so pressing Escape once more after closing a popup left full screen. The
|
||
/// IDE's windows answer it with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves it.
|
||
pub fn keep_full_screen_on_escape() {
|
||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||
ONCE.call_once(|| unsafe {
|
||
let Some(class) = AnyClass::get(c"TaoWindow") else {
|
||
tracing::warn!("no TaoWindow class: Escape leaves full screen");
|
||
return;
|
||
};
|
||
let imp: Imp = std::mem::transmute(ignore_cancel_operation as CancelOperation);
|
||
let class = class as *const AnyClass as *mut AnyClass;
|
||
let added = objc2::ffi::class_addMethod(class, objc2::sel!(cancelOperation:), imp, c"v@:@".as_ptr());
|
||
if !added.as_bool() {
|
||
tracing::warn!("could not handle cancelOperation:");
|
||
}
|
||
});
|
||
}
|
||
|
||
type CancelOperation = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
|
||
|
||
unsafe extern "C-unwind" fn ignore_cancel_operation(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) {}
|
||
|
||
/// Whether AppKit is handling a key press right now. A menu item performed at this point
|
||
/// was chosen by its key equivalent: a click, arrow keys plus Return and accessibility
|
||
/// presses all run with a non-key current event.
|
||
pub fn handling_key_press() -> bool {
|
||
let Some(mtm) = MainThreadMarker::new() else { return false };
|
||
objc2_app_kit::NSApplication::sharedApplication(mtm)
|
||
.currentEvent()
|
||
.is_some_and(|event| event.r#type() == objc2_app_kit::NSEventType::KeyDown)
|
||
}
|
||
|
||
/// Native dialogs block the workbench even when their menu event no longer carries a key press.
|
||
pub fn native_modal_open() -> bool {
|
||
let Some(mtm) = MainThreadMarker::new() else { return false };
|
||
let application = objc2_app_kit::NSApplication::sharedApplication(mtm);
|
||
application.modalWindow().is_some() || application.windows().iter().any(|window| window.attachedSheet().is_some())
|
||
}
|