дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(window): make macOS project tabs native window tabs (#2123)
Project tabs in a window group are now one native AppKit tab group behind the page's own tab bar, with AppKit's tab bar hidden. The group shares one frame and enters full screen as a whole, so every tab shows the system window buttons in the bar revealed with the menu bar, and tabs no longer lose their title bar layout after leaving full screen. This removes the full-screen auxiliary windows, the host handover on close, the frame copying between hidden members and the window buttons drawn by the page. The title bar releases the room kept for the window buttons in full screen. Linux and Windows keep showing one member at a time.
Цей коміт міститься в:
1 джерело
f07627ada6
коміт
75cb45ed94
24 files changed
+454
-1075
No files matched your search
@@ -82,8 +82,8 @@ block2 = "0.6"
|
||||
# Plugin secrets in the login keychain.
|
||||
security-framework = "3.7"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window_group::window_group_close,
|
||||
window_group::window_group_close_tabs,
|
||||
window_group::window_group_cancel_close,
|
||||
window_group::window_group_before_close,
|
||||
window_group::window_group_reorder,
|
||||
window_group::window_group_restore,
|
||||
app::light_edit_open,
|
||||
|
||||
@@ -193,17 +193,10 @@ pub fn window_open_projects(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window
|
||||
/// group's full-screen Space.
|
||||
/// Whether this window is in native full screen (on macOS, also a tab of a fullscreen group).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_is_fullscreen(window: WebviewWindow) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
crate::window_groups::in_fullscreen(window.app_handle(), &window)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
window.is_fullscreen().unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,20 +55,6 @@ pub fn window_group_cancel_close(window: WebviewWindow) {
|
||||
groups::cancel_close(window.app_handle(), window.label());
|
||||
}
|
||||
|
||||
/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space
|
||||
/// to the tab that takes over.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return groups::before_close(window.app_handle(), window.label()).await;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = window;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_group_reorder(window: WebviewWindow, labels: Vec<String>) -> IdeResult<()> {
|
||||
|
||||
@@ -190,6 +190,10 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::intercept_system_quit(app.handle());
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
macos::prepare_window_tabbing(mtm);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
application_focus::observe(app.handle());
|
||||
app.manage(menu_state);
|
||||
if let Some(paths) = setup_opens.lock().take() {
|
||||
|
||||
+59
-350
@@ -1,15 +1,13 @@
|
||||
//! macOS window tab preferences, applied to application-drawn project tabs.
|
||||
//! 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, ProtocolObject, Sel},
|
||||
runtime::{AnyClass, AnyObject, Imp, Sel},
|
||||
};
|
||||
use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
|
||||
use std::cell::Cell;
|
||||
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
|
||||
@@ -47,33 +45,13 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Opt
|
||||
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()
|
||||
});
|
||||
// A group member other than its fullscreen host is not itself in native fullscreen.
|
||||
let fullscreen = active.as_ref().is_some_and(|w| crate::window_groups::in_fullscreen(app, w));
|
||||
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())
|
||||
}
|
||||
|
||||
struct FullscreenObserver(Retained<ProtocolObject<dyn NSObjectProtocol>>);
|
||||
|
||||
impl Drop for FullscreenObserver {
|
||||
fn drop(&mut self) {
|
||||
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
|
||||
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Fullscreen hosts of window groups, watched for leaving fullscreen.
|
||||
static FULLSCREEN_HOSTS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
/// Stops watching `label`; true when it hosted a fullscreen window group.
|
||||
pub fn forget_fullscreen_host(label: &str) -> bool {
|
||||
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.remove(label)).is_some()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -86,182 +64,74 @@ fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Se
|
||||
rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage)
|
||||
}
|
||||
|
||||
/// The native `toggleFullScreen:`, bypassing the group handling installed on the window class.
|
||||
pub fn native_toggle_fullscreen(window: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = window.clone();
|
||||
on_main(window, move || {
|
||||
let (Ok(pointer), Some(native)) = (target.ns_window(), NATIVE_TOGGLE_FULLSCREEN.get()) else { return };
|
||||
unsafe { native(pointer.cast(), objc2::sel!(toggleFullScreen:), std::ptr::null_mut()) };
|
||||
})
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// A window group in fullscreen shares its host's Space: every other member joins it as an
|
||||
/// auxiliary window over the host's frame, without a frame, window buttons or dragging, like the host.
|
||||
/// Without a host the window is an ordinary one again.
|
||||
pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWindow>) -> tauri::Result<()> {
|
||||
use objc2_app_kit::{NSWindowButton, NSWindowCollectionBehavior as B};
|
||||
let target = window.clone();
|
||||
let host = host.cloned();
|
||||
on_main(window, move || {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = unsafe { &*pointer.cast::<NSWindow>() };
|
||||
let frame =
|
||||
host.and_then(|host| host.ns_window().ok()).map(|host| unsafe { &*host.cast::<NSWindow>() }.frame());
|
||||
let mut behavior = native.collectionBehavior();
|
||||
if frame.is_some() {
|
||||
behavior.remove(B::FullScreenPrimary);
|
||||
behavior.insert(B::FullScreenAuxiliary | B::MoveToActiveSpace);
|
||||
} else {
|
||||
behavior.remove(B::FullScreenAuxiliary | B::MoveToActiveSpace);
|
||||
behavior.insert(B::FullScreenPrimary);
|
||||
}
|
||||
native.setCollectionBehavior(behavior);
|
||||
// Without a title bar the window has square corners and no frame, like the host.
|
||||
let mut style = native.styleMask();
|
||||
style.set(objc2_app_kit::NSWindowStyleMask::Titled, frame.is_none());
|
||||
native.setStyleMask(style);
|
||||
native.setMovable(frame.is_none());
|
||||
// Shown over the host it must appear at once, as a tab does, not zoom in like a new window.
|
||||
native.setAnimationBehavior(if frame.is_some() {
|
||||
objc2_app_kit::NSWindowAnimationBehavior::None
|
||||
} else {
|
||||
objc2_app_kit::NSWindowAnimationBehavior::Default
|
||||
});
|
||||
for kind in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] {
|
||||
if let Some(button) = native.standardWindowButton(kind) {
|
||||
button.setHidden(frame.is_some());
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `members` in one native tab group and shows `selected`.
|
||||
pub fn sync_tab_group(members: Vec<WebviewWindow>, selected: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = selected.clone();
|
||||
on_main(selected, move || {
|
||||
let Some(shown) = native(&target) else { return };
|
||||
for member in members.iter().filter(|m| m.label() != target.label()) {
|
||||
if let Some(window) = native(member)
|
||||
&& !same_group(shown, window)
|
||||
{
|
||||
shown.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above);
|
||||
}
|
||||
}
|
||||
if let Some(frame) = frame {
|
||||
native.setFrame_display(frame, true);
|
||||
if let Some(group) = shown.tabGroup() {
|
||||
group.setSelectedWindow(Some(shown));
|
||||
}
|
||||
target.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts a group member shown in its host's fullscreen Space in front of the host, which AppKit
|
||||
/// otherwise keeps on top of the other windows in its Space.
|
||||
pub fn raise_over_host(window: &WebviewWindow, host: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = window.clone();
|
||||
let host = host.clone();
|
||||
on_main(window, move || {
|
||||
let (Ok(pointer), Ok(host)) = (target.ns_window(), host.ns_window()) else { return };
|
||||
let native = unsafe { &*pointer.cast::<NSWindow>() };
|
||||
let host = unsafe { &*host.cast::<NSWindow>() };
|
||||
native.orderWindow_relativeTo(objc2_app_kit::NSWindowOrderingMode::Above, host.windowNumber());
|
||||
})
|
||||
}
|
||||
|
||||
/// Watches the group's fullscreen `host` and runs `left` once it is back on the desktop.
|
||||
pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'static) -> tauri::Result<()> {
|
||||
let label = host.label().to_owned();
|
||||
let target = host.clone();
|
||||
on_main(host, move || unsafe {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = &*pointer.cast::<NSWindow>();
|
||||
let key = label.clone();
|
||||
let block = block2::RcBlock::new(move |_| {
|
||||
forget_fullscreen_host(&key);
|
||||
left();
|
||||
});
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWindowDidExitFullScreenNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer)));
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Windows being waited on to finish entering fullscreen.
|
||||
static ENTERING_FULLSCREEN: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`.
|
||||
pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let label = window.label().to_owned();
|
||||
let target = window.clone();
|
||||
on_main(window, move || unsafe {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = &*pointer.cast::<NSWindow>();
|
||||
let tx = std::cell::Cell::new(Some(tx));
|
||||
let block = block2::RcBlock::new(move |_| {
|
||||
if let Some(tx) = tx.take() {
|
||||
let _ = tx.send(());
|
||||
shown.makeKeyAndOrderFront(None);
|
||||
for member in &members {
|
||||
if let Some(window) = native(member) {
|
||||
hide_tab_bar(window);
|
||||
}
|
||||
});
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer)));
|
||||
})?;
|
||||
let toggled = native_toggle_fullscreen(window);
|
||||
if toggled.is_ok() {
|
||||
let _ = tokio::time::timeout(timeout, rx).await;
|
||||
}
|
||||
let label = window.label().to_owned();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 || {
|
||||
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label));
|
||||
})?;
|
||||
toggled
|
||||
let Some(native) = native(&target) else { return };
|
||||
if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) {
|
||||
native.moveTabToNewWindow(None);
|
||||
}
|
||||
hide_tab_bar(native);
|
||||
})
|
||||
}
|
||||
|
||||
/// An observer on the workspace's notification center, removed when dropped.
|
||||
struct WorkspaceObserver(Retained<NSNotificationCenter>, Retained<ProtocolObject<dyn NSObjectProtocol>>);
|
||||
|
||||
impl Drop for WorkspaceObserver {
|
||||
fn drop(&mut self) {
|
||||
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.1;
|
||||
unsafe { self.0.removeObserver(observer.as_ref()) };
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/// Work waiting for the active Space to change, with the observer that runs it.
|
||||
struct SpaceChange {
|
||||
_observer: WorkspaceObserver,
|
||||
then: Box<dyn FnOnce()>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SPACE_CHANGE: std::cell::RefCell<Option<SpaceChange>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `then` on the main thread once the active Space has changed, or after `timeout` when it
|
||||
/// changed before this was called. AppKit drops a fullscreen request made while a fullscreen
|
||||
/// Space is being torn down.
|
||||
pub fn after_space_change(app: &AppHandle, timeout: std::time::Duration, then: impl FnOnce() + Send + 'static) {
|
||||
fn run() {
|
||||
if let Some(pending) = SPACE_CHANGE.with_borrow_mut(Option::take) {
|
||||
(pending.then)();
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
run();
|
||||
let block = block2::RcBlock::new(|_| run());
|
||||
let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter();
|
||||
let observer = unsafe {
|
||||
center.addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
)
|
||||
};
|
||||
let observer = WorkspaceObserver(center, observer);
|
||||
SPACE_CHANGE
|
||||
.with_borrow_mut(|pending| *pending = Some(SpaceChange { _observer: observer, then: Box::new(then) }));
|
||||
});
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(timeout).await;
|
||||
let _ = app.run_on_main_thread(run);
|
||||
});
|
||||
}
|
||||
|
||||
type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
|
||||
@@ -301,165 +171,4 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
|
||||
TERMINATE_CANCEL
|
||||
}
|
||||
|
||||
type ToggleFullScreen = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
|
||||
|
||||
static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock<ToggleFullScreen> = std::sync::OnceLock::new();
|
||||
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
|
||||
|
||||
/// The green window button, the Globe-F shortcut and the system's Enter Full Screen item all
|
||||
/// send `toggleFullScreen:`; a grouped window answers it for the whole group.
|
||||
pub fn intercept_toggle_fullscreen(window: &WebviewWindow) {
|
||||
let _ = APP.set(window.app_handle().clone());
|
||||
let _ = window.with_webview(|webview| unsafe {
|
||||
let class = objc2::ffi::object_getClass(webview.ns_window().cast()) as *mut AnyClass;
|
||||
let selector = objc2::sel!(toggleFullScreen:);
|
||||
if NATIVE_TOGGLE_FULLSCREEN.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(method) = (*class).instance_method(selector) else { return };
|
||||
let native: ToggleFullScreen = std::mem::transmute(method.implementation());
|
||||
let types = objc2::ffi::method_getTypeEncoding(method);
|
||||
let _ = NATIVE_TOGGLE_FULLSCREEN.set(native);
|
||||
let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
|
||||
let selector = objc2::sel!(constrainFrameRect:toScreen:);
|
||||
let Some(method) = (*class).instance_method(selector) else { return };
|
||||
let native: ConstrainFrameRect = std::mem::transmute(method.implementation());
|
||||
let types = objc2::ffi::method_getTypeEncoding(method);
|
||||
let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native);
|
||||
let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
});
|
||||
}
|
||||
|
||||
type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect;
|
||||
|
||||
static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock<ConstrainFrameRect> = std::sync::OnceLock::new();
|
||||
|
||||
thread_local! {
|
||||
/// Native window whose next frame assignment must keep the rectangle it was given.
|
||||
static EXACT_FRAME: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
struct ExactFrame(usize);
|
||||
|
||||
impl ExactFrame {
|
||||
fn begin(native: usize) -> Self {
|
||||
EXACT_FRAME.with(|slot| slot.set(native));
|
||||
Self(native)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExactFrame {
|
||||
fn drop(&mut self) {
|
||||
EXACT_FRAME.with(|slot| {
|
||||
if slot.get() == self.0 {
|
||||
slot.set(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns;
|
||||
/// tao's position and size setters only schedule the change.
|
||||
pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
|
||||
apply_window_frame(from, to, false)
|
||||
}
|
||||
|
||||
/// Copies the frame and orders `to` front before AppKit can animate or constrain it.
|
||||
/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place.
|
||||
pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
|
||||
apply_window_frame(from, to, true)
|
||||
}
|
||||
|
||||
fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
|
||||
if MainThreadMarker::new().is_some() {
|
||||
return apply_window_frame_on_main(from, to, show);
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
let from = from.clone();
|
||||
let to = to.clone();
|
||||
to.clone().run_on_main_thread(move || {
|
||||
let _ = tx.send(apply_window_frame_on_main(&from, &to, show));
|
||||
})?;
|
||||
rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))?
|
||||
}
|
||||
|
||||
fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
|
||||
let source_pointer = from.ns_window()?;
|
||||
let target_pointer = to.ns_window()?;
|
||||
unsafe {
|
||||
let source = &*source_pointer.cast::<NSWindow>();
|
||||
let target = &*target_pointer.cast::<NSWindow>();
|
||||
let frame = source.frame();
|
||||
let current = target.frame();
|
||||
let differs = frame.origin.x != current.origin.x
|
||||
|| frame.origin.y != current.origin.y
|
||||
|| frame.size.width != current.size.width
|
||||
|| frame.size.height != current.size.height;
|
||||
if !differs && !show {
|
||||
return Ok(());
|
||||
}
|
||||
// Zoom and ordering use the window's animation behavior. `animate: false` alone does not
|
||||
// stop a zoomed window from sliding to the new frame as it is shown.
|
||||
let behavior = target.animationBehavior();
|
||||
target.setAnimationBehavior(NSWindowAnimationBehavior::None);
|
||||
let result = (|| {
|
||||
let _exact = ExactFrame::begin(target_pointer as usize);
|
||||
if differs {
|
||||
target.setFrame_display_animate(frame, false, false);
|
||||
}
|
||||
if show {
|
||||
to.show()?;
|
||||
to.set_focus()?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
target.setAnimationBehavior(behavior);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the
|
||||
/// camera housing, whenever the window is placed, shown or resized. A group-tab frame copy keeps
|
||||
/// the rectangle it was given, so the window about to be shown is not shifted.
|
||||
unsafe extern "C-unwind" fn constrain_frame_rect(
|
||||
this: *mut AnyObject,
|
||||
selector: Sel,
|
||||
frame: NSRect,
|
||||
screen: *mut AnyObject,
|
||||
) -> NSRect {
|
||||
if EXACT_FRAME.with(|slot| slot.get() == this as usize) {
|
||||
return frame;
|
||||
}
|
||||
match NATIVE_CONSTRAIN_FRAME_RECT.get() {
|
||||
Some(native) => unsafe { native(this, selector, frame, screen) },
|
||||
None => frame,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) {
|
||||
if let Some(window) = APP.get().and_then(|app| grouped_window(app, this)) {
|
||||
if let Err(error) = crate::window_groups::toggle_fullscreen(&window) {
|
||||
tracing::warn!(message = error.message, "cannot toggle fullscreen");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(native) = NATIVE_TOGGLE_FULLSCREEN.get() {
|
||||
unsafe { native(this, selector, sender) };
|
||||
}
|
||||
}
|
||||
|
||||
fn project_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
|
||||
app.webview_windows()
|
||||
.into_values()
|
||||
.find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::<AnyObject>() == native))
|
||||
}
|
||||
|
||||
/// `native` as a project window in a window group.
|
||||
fn grouped_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
|
||||
let window = project_window(app, native)?;
|
||||
let grouped = app.state::<WindowsState>().groups.lock().group(window.label()).is_some();
|
||||
grouped.then_some(window)
|
||||
}
|
||||
@@ -12,8 +12,9 @@ use objc2::{
|
||||
runtime::ProtocolObject,
|
||||
};
|
||||
use objc2_app_kit::{
|
||||
NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
|
||||
NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification,
|
||||
NSToolbar, NSWindow, NSWindowDidBecomeKeyNotification, NSWindowDidEnterFullScreenNotification,
|
||||
NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
|
||||
NSWindowWillEnterFullScreenNotification,
|
||||
};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
|
||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||
@@ -56,7 +57,7 @@ impl Drop for Observer {
|
||||
|
||||
struct Tracked {
|
||||
state: Rc<Fullscreen>,
|
||||
_observers: [Observer; 3],
|
||||
_observers: [Observer; 4],
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -85,8 +86,9 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
|
||||
change(&state);
|
||||
if let Some(window) = weak.load() {
|
||||
apply(&window, &state);
|
||||
crate::macos::hide_tab_bar(&window);
|
||||
}
|
||||
// The web title bar shows the window buttons itself in full screen.
|
||||
// The web title bar releases the room kept for the window buttons in full screen.
|
||||
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
|
||||
});
|
||||
let observer = unsafe {
|
||||
@@ -104,6 +106,8 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
|
||||
observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter),
|
||||
observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter),
|
||||
observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit),
|
||||
// A tab shown in its group: AppKit's tab bar moves to it, and it may be in full screen.
|
||||
observe(NSWindowDidBecomeKeyNotification, |_| {}),
|
||||
]
|
||||
};
|
||||
Tracked { state, _observers: observers }
|
||||
|
||||
@@ -8,22 +8,13 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
|
||||
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
|
||||
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
|
||||
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
|
||||
/// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SPACE_TEARDOWN: Duration = Duration::from_millis(1500);
|
||||
/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SPACE_HANDOVER: Duration = Duration::from_millis(2000);
|
||||
/// Coalesces the move/resize events a drag emits.
|
||||
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod frames;
|
||||
|
||||
type Group = ide_workspace::window_groups::WindowGroup;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -62,410 +53,57 @@ fn changed(app: &AppHandle) {
|
||||
app.state::<AppState>().sink.emit(IdeEvent::WindowGroupsChanged);
|
||||
}
|
||||
|
||||
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
|
||||
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OuterFrame {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Where a group member can be placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameHost {
|
||||
/// The client can move and resize the window.
|
||||
Desktop,
|
||||
/// Wayland does not let clients place top-level windows.
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
Wayland,
|
||||
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
|
||||
NativeFullscreen,
|
||||
}
|
||||
|
||||
/// What to write before the target window is shown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameWrite {
|
||||
/// Already the same rectangle, or this host must not be placed.
|
||||
Leave,
|
||||
TransferFullscreen,
|
||||
Assign(OuterFrame),
|
||||
}
|
||||
|
||||
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
|
||||
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
|
||||
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
|
||||
match host {
|
||||
FrameHost::Wayland => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
|
||||
FrameHost::Desktop if from == to => FrameWrite::Leave,
|
||||
FrameHost::Desktop => FrameWrite::Assign(from),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
let position = window.outer_position().map_err(err)?;
|
||||
let size = window.outer_size().map_err(err)?;
|
||||
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
|
||||
}
|
||||
|
||||
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(FrameHost::Wayland);
|
||||
}
|
||||
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
|
||||
return Ok(FrameHost::NativeFullscreen);
|
||||
}
|
||||
Ok(FrameHost::Desktop)
|
||||
}
|
||||
|
||||
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
|
||||
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
|
||||
/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
// Wayland does not let clients place top-level windows; maximizing stays on that output.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(());
|
||||
}
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if let Some(monitor) = from.current_monitor().map_err(err)? {
|
||||
to.set_position(*monitor.position()).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives `to` the source window's outer frame before `to` is shown.
|
||||
///
|
||||
/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return
|
||||
/// before the frame changes, while `show` orders the window front immediately, so the old
|
||||
/// rectangle appears and then jumps. The frame is written synchronously instead.
|
||||
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let host = frame_host(from)?;
|
||||
if host == FrameHost::Wayland {
|
||||
return Ok(());
|
||||
}
|
||||
// Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is
|
||||
// the one `show` will present, so compare that instead of skipping a real difference.
|
||||
#[cfg(target_os = "macos")]
|
||||
if host == FrameHost::Desktop {
|
||||
return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()));
|
||||
}
|
||||
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
|
||||
FrameWrite::Leave => Ok(()),
|
||||
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
|
||||
FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
// A macOS group in fullscreen shares its host's Space, so no member copies the host's frame.
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (from, to, err);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
if from.is_maximized().map_err(err)? {
|
||||
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
|
||||
// alone are not enough; a hidden peer would otherwise appear at its old size.
|
||||
to.unmaximize().map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
to.maximize().map_err(err)?;
|
||||
} else {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
to.set_size(size).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `to` in place of `from`, at `from`'s frame.
|
||||
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
// Order the target front in the same AppKit turn as the frame write. A separate `show` runs
|
||||
// after that turn and constrains the titled window, which is the jump. Native fullscreen is
|
||||
// handed over by `copy_frame` instead.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !from.is_fullscreen().map_err(err)? {
|
||||
to.unminimize().map_err(err)?;
|
||||
return crate::macos::present_window_frame(from, to).map_err(err);
|
||||
}
|
||||
copy_frame(from, to)?;
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)
|
||||
}
|
||||
|
||||
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
|
||||
/// changes which window is visible instead of moving or resizing one on screen.
|
||||
fn sync_member_frames(app: &AppHandle, selected: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
|
||||
if group.selected != selected {
|
||||
return;
|
||||
}
|
||||
let Some(from) = app.get_webview_window(selected) else { return };
|
||||
if from.is_fullscreen().unwrap_or(false)
|
||||
|| from.is_minimized().unwrap_or(false)
|
||||
|| fullscreen_host(app, &group.members).is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
for member in group.members.iter().filter(|member| member.as_str() != selected) {
|
||||
let Some(to) = app.get_webview_window(member) else { continue };
|
||||
// A visible peer is the one being presented; never move it behind the user's back.
|
||||
if to.is_visible().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = copy_frame(&from, &to) {
|
||||
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
|
||||
/// the next tab selection is only a visibility change.
|
||||
/// A presented group window moved or resized. Native tabs (macOS) share one frame already.
|
||||
pub fn frame_changed(app: &AppHandle, label: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
|
||||
if group.selected != label {
|
||||
return;
|
||||
}
|
||||
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let app = app.clone();
|
||||
let label = label.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
|
||||
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
|
||||
return;
|
||||
}
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The member of `members` in native fullscreen. On macOS the other members of its group join
|
||||
/// its Space; elsewhere each member of a fullscreen group is fullscreen itself.
|
||||
fn fullscreen_host(app: &AppHandle, members: &[String]) -> Option<WebviewWindow> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false));
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app, members);
|
||||
None
|
||||
}
|
||||
frames::frame_changed(app, label);
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (app, label);
|
||||
}
|
||||
|
||||
/// The fullscreen host of `label`'s group, when that is another window.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn fullscreen_host_of(app: &AppHandle, label: &str) -> Option<WebviewWindow> {
|
||||
let members = app.state::<WindowsState>().groups.lock().group(label).map(|g| g.members.clone())?;
|
||||
fullscreen_host(app, &members).filter(|host| host.label() != label)
|
||||
}
|
||||
|
||||
/// Whether `window` is shown fullscreen: itself, or as a member of a fullscreen group.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn in_fullscreen(app: &AppHandle, window: &WebviewWindow) -> bool {
|
||||
if window.is_fullscreen().unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
let members = app.state::<WindowsState>().groups.lock().group(window.label()).map(|g| g.members.clone());
|
||||
members.is_some_and(|members| fullscreen_host(app, &members).is_some())
|
||||
}
|
||||
|
||||
/// Fullscreen for `window`. On macOS a group shares one fullscreen Space: the selected member
|
||||
/// enters native fullscreen and the others join its Space as auxiliary windows over it, so
|
||||
/// switching projects only shows one window and hides the others.
|
||||
/// Fullscreen for `window`. On macOS a group is one native tab group, which AppKit takes into a
|
||||
/// fullscreen Space as a whole.
|
||||
pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult<bool> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app = window.app_handle();
|
||||
let group = app.state::<WindowsState>().groups.lock().group(window.label()).cloned();
|
||||
if let Some(group) = group {
|
||||
if let Some(host) = fullscreen_host(app, &group.members) {
|
||||
// The host's watch brings the members back with it.
|
||||
crate::macos::native_toggle_fullscreen(&host).map_err(err)?;
|
||||
return Ok(false);
|
||||
}
|
||||
let host = app.get_webview_window(&group.selected).unwrap_or_else(|| window.clone());
|
||||
host_fullscreen(app, &host)?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let on = !window.is_fullscreen().map_err(err)?;
|
||||
window.set_fullscreen(on).map_err(err)?;
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
/// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
prepare_host(app, host)?;
|
||||
crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// `host` and the other members of its group, ready for `host` to enter fullscreen.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let members = app.state::<WindowsState>().groups.lock().group(host.label()).map(|g| g.members.clone());
|
||||
crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?;
|
||||
for member in members.iter().flatten().filter(|m| *m != host.label()) {
|
||||
if let Some(other) = app.get_webview_window(member) {
|
||||
crate::macos::set_fullscreen_auxiliary(&other, Some(host)).map_err(err)?;
|
||||
}
|
||||
}
|
||||
if members.is_some() {
|
||||
watch_host(app, host)?;
|
||||
}
|
||||
host.show().map_err(err)
|
||||
}
|
||||
|
||||
/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves
|
||||
/// for a Space of its own first, so the screen moves straight to it; were the host closed first,
|
||||
/// its Space would end and show the desktop until the next one opened.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) };
|
||||
if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) {
|
||||
return Ok(());
|
||||
}
|
||||
// Detached now, the closing host leaves nothing for `destroyed` to hand over.
|
||||
let next = state.groups.lock().detach(caller);
|
||||
changed(app);
|
||||
let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) };
|
||||
prepare_host(app, &next)?;
|
||||
crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
windows::focus(&next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn watch_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
let app = app.clone();
|
||||
let label = host.label().to_owned();
|
||||
crate::macos::watch_fullscreen_host(host, move || left_fullscreen(&app, &label))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// The group's fullscreen host is back on the desktop: the members leave its Space with it at
|
||||
/// its frame, and the selected one is shown.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn left_fullscreen(app: &AppHandle, host: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(host).cloned() else { return };
|
||||
let Some(host) = app.get_webview_window(host) else { return };
|
||||
for member in group.members.iter().filter(|m| *m != host.label()) {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
let _ = crate::macos::set_fullscreen_auxiliary(&window, None);
|
||||
let _ = copy_frame(&host, &window);
|
||||
}
|
||||
}
|
||||
let _ = select(app, &group.selected, &group.selected);
|
||||
}
|
||||
|
||||
pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
|
||||
let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?;
|
||||
let state = app.state::<WindowsState>();
|
||||
let previous = state.groups.lock().select(caller, target)?;
|
||||
// In a fullscreen group the target joins the host's Space, covering its screen.
|
||||
let host = fullscreen_host(app, &previous.members);
|
||||
let result = (|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = &host {
|
||||
// Also a window that was fullscreen alone before it was grouped.
|
||||
watch_host(app, host)?;
|
||||
if host.label() != target {
|
||||
crate::macos::set_fullscreen_auxiliary(&to, Some(host))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
if host.is_none()
|
||||
&& previous.selected != target
|
||||
&& let Some(from) = app.get_webview_window(&previous.selected)
|
||||
{
|
||||
show_at_frame(&from, &to)?;
|
||||
} else {
|
||||
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
to.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = host.as_ref().filter(|h| h.label() != target) {
|
||||
crate::macos::raise_over_host(&to, host).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
for member in &previous.members {
|
||||
// The host stays in its Space behind the selected member; hiding it would end the Space.
|
||||
if member != target
|
||||
&& host.as_ref().is_none_or(|h| h.label() != member)
|
||||
&& let Some(window) = app.get_webview_window(member)
|
||||
{
|
||||
window.hide().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = native_select(app, &previous, &to);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let result = frames::present(app, &previous, target, &to);
|
||||
if result.is_err() {
|
||||
let _ = state.groups.lock().select(caller, &previous.selected);
|
||||
for member in &previous.members {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
if member == &previous.selected {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = app.get_webview_window(&previous.selected) {
|
||||
let _ = native_select(app, &previous, &window);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
frames::restore_selection(app, &previous);
|
||||
} else if !state.quitting() {
|
||||
// Align the members that are now hidden while they stay invisible, so the next
|
||||
// selection does not move or resize a window on screen. Hiding is asynchronous;
|
||||
// wait for it before moving a peer that is still on screen.
|
||||
let app = app.clone();
|
||||
let target = target.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &target);
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
frames::selected(app, target);
|
||||
}
|
||||
changed(app);
|
||||
// A tab shown in a fullscreen group is in full screen too; its title bar asks again.
|
||||
app.state::<AppState>().sink.emit(IdeEvent::WindowFullscreenChanged);
|
||||
result
|
||||
}
|
||||
|
||||
/// Shows `to` as the selected tab of the group's native tab group.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn native_select(app: &AppHandle, group: &Group, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let members = group.members.iter().filter_map(|m| app.get_webview_window(m)).collect();
|
||||
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
crate::macos::sync_tab_group(members, to).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
@@ -492,88 +130,43 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let state = app.state::<WindowsState>();
|
||||
#[cfg(target_os = "macos")]
|
||||
let host = {
|
||||
let members = state.groups.lock().group(caller).map(|g| g.members.clone()).unwrap_or_default();
|
||||
fullscreen_host(app, &members)
|
||||
};
|
||||
let next = state.groups.lock().detach(caller);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = host {
|
||||
detach_fullscreen(app, caller, &host, next.as_deref())?;
|
||||
let Some(next) = state.groups.lock().detach(caller) else {
|
||||
changed(app);
|
||||
return Ok(());
|
||||
};
|
||||
let window = app.get_webview_window(caller);
|
||||
// A tab moved out of a fullscreen group stays fullscreen in a Space of its own.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = &window {
|
||||
crate::macos::move_tab_to_new_window(window).map_err(err)?;
|
||||
}
|
||||
if let Some(next) = next {
|
||||
if let Some(window) = app.get_webview_window(&next) {
|
||||
window.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if let Some(next) = app.get_webview_window(&next) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if state.groups.lock().group(next.label()).is_some() {
|
||||
select(app, next.label(), next.label())?;
|
||||
}
|
||||
if let Some(window) = app.get_webview_window(caller) {
|
||||
if let Ok(position) = window.outer_position() {
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
|
||||
}
|
||||
windows::focus(&window);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
next.show().map_err(err)?;
|
||||
}
|
||||
if let Some(window) = window {
|
||||
if !window.is_fullscreen().map_err(err)?
|
||||
&& let Ok(position) = window.outer_position()
|
||||
{
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
|
||||
}
|
||||
windows::focus(&window);
|
||||
}
|
||||
changed(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tab moved out of a fullscreen group stays fullscreen in a Space of its own, as a macOS
|
||||
/// tab moved to a new window does; the tabs left keep theirs.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detach_fullscreen(app: &AppHandle, caller: &str, host: &WebviewWindow, next: Option<&str>) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let next = next.and_then(|label| app.get_webview_window(label));
|
||||
let grouped = |label: &str| app.state::<WindowsState>().groups.lock().group(label).is_some();
|
||||
if host.label() == caller {
|
||||
// The host keeps its Space; the tabs left need a host of their own.
|
||||
crate::macos::forget_fullscreen_host(caller);
|
||||
if let Some(next) = &next {
|
||||
host_fullscreen(app, next)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(next) = &next {
|
||||
if grouped(next.label()) {
|
||||
select(app, next.label(), next.label())?;
|
||||
} else {
|
||||
// The group dissolved; its last window is fullscreen alone.
|
||||
crate::macos::forget_fullscreen_host(next.label());
|
||||
crate::macos::set_fullscreen_auxiliary(next, None).map_err(err)?;
|
||||
}
|
||||
}
|
||||
if let Some(window) = app.get_webview_window(caller) {
|
||||
host_fullscreen(app, &window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn destroyed(app: &AppHandle, caller: &str) {
|
||||
let Some(state) = app.try_state::<WindowsState>() else { return };
|
||||
#[cfg(target_os = "macos")]
|
||||
let hosted = crate::macos::forget_fullscreen_host(caller);
|
||||
let next = state.groups.lock().detach(caller);
|
||||
if !state.quitting() {
|
||||
if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) {
|
||||
// The Space ended with its host; the tabs left take a new one once it is gone.
|
||||
#[cfg(target_os = "macos")]
|
||||
if hosted {
|
||||
let host_app = app.clone();
|
||||
crate::macos::after_space_change(app, SPACE_TEARDOWN, move || {
|
||||
if let Err(error) = host_fullscreen(&host_app, &window) {
|
||||
tracing::warn!("cannot keep window group fullscreen: {}", error.message);
|
||||
}
|
||||
windows::focus(&window);
|
||||
});
|
||||
} else {
|
||||
windows::focus(&window);
|
||||
if let Some(host) = fullscreen_host_of(app, window.label()) {
|
||||
let _ = crate::macos::raise_over_host(&window, &host);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
windows::focus(&window);
|
||||
}
|
||||
let app = app.clone();
|
||||
@@ -773,47 +366,3 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
|
||||
|
||||
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
|
||||
OuterFrame { x, y, width, height }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_outer_rects_are_left_alone() {
|
||||
let frame = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
|
||||
let from = frame(100, 80, 1200, 800);
|
||||
let to = frame(40, 40, 900, 600);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_windows_with_different_rects_still_assign() {
|
||||
let from = frame(0, 25, 1728, 1055);
|
||||
let to = frame(0, 25, 1512, 900);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_does_not_place_a_window() {
|
||||
let from = frame(0, 0, 1200, 800);
|
||||
let to = frame(10, 10, 800, 600);
|
||||
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fullscreen_transfers_only_when_the_rects_differ() {
|
||||
let from = frame(0, 0, 1728, 1117);
|
||||
let other = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Window groups without native tabs: one member is shown at a time, and the hidden ones are kept
|
||||
//! at its frame so that selecting a tab only changes which window is visible.
|
||||
use super::Group;
|
||||
use crate::windows::WindowsState;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
|
||||
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
|
||||
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
|
||||
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
|
||||
/// Coalesces the move/resize events a drag emits.
|
||||
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
|
||||
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OuterFrame {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Where a group member can be placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameHost {
|
||||
/// The client can move and resize the window.
|
||||
Desktop,
|
||||
/// Wayland does not let clients place top-level windows.
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
Wayland,
|
||||
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
|
||||
NativeFullscreen,
|
||||
}
|
||||
|
||||
/// What to write before the target window is shown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameWrite {
|
||||
/// Already the same rectangle, or this host must not be placed.
|
||||
Leave,
|
||||
TransferFullscreen,
|
||||
Assign(OuterFrame),
|
||||
}
|
||||
|
||||
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
|
||||
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
|
||||
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
|
||||
match host {
|
||||
FrameHost::Wayland => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
|
||||
FrameHost::Desktop if from == to => FrameWrite::Leave,
|
||||
FrameHost::Desktop => FrameWrite::Assign(from),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
let position = window.outer_position().map_err(err)?;
|
||||
let size = window.outer_size().map_err(err)?;
|
||||
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
|
||||
}
|
||||
|
||||
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(FrameHost::Wayland);
|
||||
}
|
||||
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
|
||||
return Ok(FrameHost::NativeFullscreen);
|
||||
}
|
||||
Ok(FrameHost::Desktop)
|
||||
}
|
||||
|
||||
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
|
||||
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
|
||||
/// visibly jump across displays when its tab is selected.
|
||||
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
// Wayland does not let clients place top-level windows; maximizing stays on that output.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(());
|
||||
}
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if let Some(monitor) = from.current_monitor().map_err(err)? {
|
||||
to.set_position(*monitor.position()).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives `to` the source window's outer frame before `to` is shown.
|
||||
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let host = frame_host(from)?;
|
||||
if host == FrameHost::Wayland {
|
||||
return Ok(());
|
||||
}
|
||||
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
|
||||
FrameWrite::Leave => Ok(()),
|
||||
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
|
||||
FrameWrite::Assign(_) => assign_outer_frame(from, to),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
if from.is_maximized().map_err(err)? {
|
||||
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
|
||||
// alone are not enough; a hidden peer would otherwise appear at its old size.
|
||||
to.unmaximize().map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
to.maximize().map_err(err)?;
|
||||
} else {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
to.set_size(size).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows `to` in place of `from`, at `from`'s frame.
|
||||
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
copy_frame(from, to)?;
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)
|
||||
}
|
||||
|
||||
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
|
||||
/// changes which window is visible instead of moving or resizing one on screen.
|
||||
fn sync_member_frames(app: &AppHandle, selected: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
|
||||
if group.selected != selected {
|
||||
return;
|
||||
}
|
||||
let Some(from) = app.get_webview_window(selected) else { return };
|
||||
if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
for member in group.members.iter().filter(|member| member.as_str() != selected) {
|
||||
let Some(to) = app.get_webview_window(member) else { continue };
|
||||
// A visible peer is the one being presented; never move it behind the user's back.
|
||||
if to.is_visible().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = copy_frame(&from, &to) {
|
||||
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
|
||||
/// the next tab selection is only a visibility change.
|
||||
pub(super) fn frame_changed(app: &AppHandle, label: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
|
||||
if group.selected != label {
|
||||
return;
|
||||
}
|
||||
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let app = app.clone();
|
||||
let label = label.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
|
||||
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
|
||||
return;
|
||||
}
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows `to` in place of the group's previously selected member and hides the others.
|
||||
pub(super) fn present(app: &AppHandle, previous: &Group, target: &str, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if previous.selected != target
|
||||
&& let Some(from) = app.get_webview_window(&previous.selected)
|
||||
{
|
||||
show_at_frame(&from, to)?;
|
||||
} else {
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)?;
|
||||
}
|
||||
for member in &previous.members {
|
||||
if member != target
|
||||
&& let Some(window) = app.get_webview_window(member)
|
||||
{
|
||||
window.hide().map_err(err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the previously selected member again after a failed selection.
|
||||
pub(super) fn restore_selection(app: &AppHandle, previous: &Group) {
|
||||
for member in &previous.members {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
if member == &previous.selected {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligns the members that are now hidden while they stay invisible, so the next selection does
|
||||
/// not move or resize a window on screen. Hiding is asynchronous; wait for it before moving a
|
||||
/// peer that is still on screen.
|
||||
pub(super) fn selected(app: &AppHandle, target: &str) {
|
||||
let app = app.clone();
|
||||
let target = target.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
|
||||
|
||||
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
|
||||
OuterFrame { x, y, width, height }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_outer_rects_are_left_alone() {
|
||||
let frame = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
|
||||
let from = frame(100, 80, 1200, 800);
|
||||
let to = frame(40, 40, 900, 600);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_windows_with_different_rects_still_assign() {
|
||||
let from = frame(0, 25, 1728, 1055);
|
||||
let to = frame(0, 25, 1512, 900);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_does_not_place_a_window() {
|
||||
let from = frame(0, 0, 1200, 800);
|
||||
let to = frame(10, 10, 800, 600);
|
||||
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fullscreen_transfers_only_when_the_rects_differ() {
|
||||
let from = frame(0, 0, 1728, 1117);
|
||||
let other = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
|
||||
}
|
||||
}
|
||||
@@ -699,8 +699,6 @@ fn create_window_for(
|
||||
}
|
||||
let window = window?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos::intercept_toggle_fullscreen(&window);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(target) = tab_target {
|
||||
crate::window_groups::join(app, target.label(), window.label())?;
|
||||
return Ok(window);
|
||||
|
||||
@@ -42,11 +42,6 @@ export const commands = {
|
||||
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
|
||||
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
|
||||
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
|
||||
/**
|
||||
* This window closes next (its prompts passed): a fullscreen group's host first hands its Space
|
||||
* to the tab that takes over.
|
||||
*/
|
||||
windowGroupBeforeClose: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_before_close")),
|
||||
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
|
||||
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
|
||||
/**
|
||||
@@ -925,10 +920,7 @@ export const commands = {
|
||||
windowFlashTaskbar: (flash: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("window_flash_taskbar", { flash })),
|
||||
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
|
||||
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
|
||||
/**
|
||||
* Whether this window fills the screen: in native full screen, or (macOS) shown in its window
|
||||
* group's full-screen Space.
|
||||
*/
|
||||
/** Whether this window is in native full screen (on macOS, also a tab of a fullscreen group). */
|
||||
windowIsFullscreen: () => __TAURI_INVOKE<boolean>("window_is_fullscreen"),
|
||||
/**
|
||||
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
|
||||
@@ -205,7 +205,6 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
deps,
|
||||
);
|
||||
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
|
||||
else if (!ctx.quitting) await commands.windowGroupBeforeClose();
|
||||
return ok;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("theme tokens", () => {
|
||||
* on purpose: colours the user assigns (project and data source colours), the
|
||||
* platform's caption close and macOS window buttons, and the tint strength.
|
||||
*/
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/;
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
|
||||
|
||||
describe("palette themes", () => {
|
||||
const ISLANDS = ["light", "dark", "dark-hc"];
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"window.caption.minimize": "Minimize",
|
||||
"window.caption.maximize": "Maximize",
|
||||
"window.caption.restore": "Restore Down",
|
||||
"window.caption.exitFullScreen": "Exit Full Screen",
|
||||
"window.caption.close": "Close",
|
||||
"menu.help.keymapReference": "Keymap Reference",
|
||||
"menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"window.caption.minimize": "最小化",
|
||||
"window.caption.maximize": "最大化",
|
||||
"window.caption.restore": "向下还原",
|
||||
"window.caption.exitFullScreen": "退出全屏",
|
||||
"window.caption.close": "关闭",
|
||||
"menu.help.keymapReference": "快捷键参考",
|
||||
"menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}",
|
||||
|
||||
@@ -436,18 +436,6 @@ html {
|
||||
--fg-caption-close-hover: #ffffff;
|
||||
}
|
||||
|
||||
/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */
|
||||
:root,
|
||||
:root[data-theme="light"],
|
||||
:root[data-theme="dark-hc"] {
|
||||
--mac-window-close: #ff5f57;
|
||||
--mac-window-minimize: #febc2e;
|
||||
--mac-window-zoom: #28c840;
|
||||
--mac-window-glyph: rgb(0 0 0 / 55%);
|
||||
--mac-window-edge: rgb(0 0 0 / 12%);
|
||||
--mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* Islands Light. Every colour token of :root is redefined here. Values are
|
||||
* PyCharm's own; its secondary greys, comment / metadata colours, line numbers
|
||||
|
||||
@@ -252,9 +252,6 @@ const ICONS = {
|
||||
"window-maximize": "openidea/general/windowMaximize",
|
||||
"window-restore": "openidea/general/windowRestore",
|
||||
"window-close": "openidea/general/windowClose",
|
||||
"mac-window-close": "openidea/general/macWindowClose",
|
||||
"mac-window-minimize": "openidea/general/macWindowMinimize",
|
||||
"mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen",
|
||||
hide: "general/hide",
|
||||
pin: "general/pin",
|
||||
eye: "general/show",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
До Ширина: | Висота: | Розмір: 299 B |
@@ -1,5 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (exit full screen), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75 2.5V7.25H13.5L8.75 2.5Z" fill="#6C707E"/>
|
||||
<path d="M7.25 13.5V8.75H2.5L7.25 13.5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
До Ширина: | Висота: | Розмір: 319 B |
@@ -1,4 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (minimize), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 8H12.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
До Ширина: | Висота: | Розмір: 277 B |
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* macOS window buttons in the title bar's slot while the window fills the screen. Native full
|
||||
* screen moves the system's buttons into the bar revealed with the menu bar, and a project tab
|
||||
* shown in its group's full-screen Space has none, so the slot kept for them would be empty.
|
||||
* Close closes this project's tab, the green button leaves full screen, minimize is disabled.
|
||||
* Like the system's, they take no keyboard focus.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { Icon } from "@/ui";
|
||||
import styles from "./TitleBar.module.css";
|
||||
|
||||
type AppWindow = import("@tauri-apps/api/window").Window;
|
||||
|
||||
function useFullscreenWindow(): { fullscreen: boolean; active: boolean } {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [active, setActive] = useState(() => document.hasFocus());
|
||||
useEffect(() => {
|
||||
// The plain-browser shell and tests have no Tauri window to ask.
|
||||
if (!isTauri()) return;
|
||||
let disposed = false;
|
||||
const offs: (() => void)[] = [];
|
||||
const sync = () =>
|
||||
void commands
|
||||
.windowIsFullscreen()
|
||||
.then((on) => !disposed && setFullscreen(on))
|
||||
.catch(() => {});
|
||||
sync();
|
||||
offs.push(onIdeEvent("windowFullscreenChanged", sync));
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
const w: AppWindow = getCurrentWindow();
|
||||
const listeners = await Promise.all([
|
||||
w.onResized(sync),
|
||||
w.onFocusChanged(({ payload }) => {
|
||||
setActive(payload);
|
||||
sync();
|
||||
}),
|
||||
]);
|
||||
if (disposed) listeners.forEach((off) => off());
|
||||
else offs.push(...listeners);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
offs.forEach((off) => off());
|
||||
};
|
||||
}, []);
|
||||
return { fullscreen, active };
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
if (!isTauri()) return;
|
||||
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close());
|
||||
}
|
||||
|
||||
export function MacWindowButtons() {
|
||||
const { fullscreen, active } = useFullscreenWindow();
|
||||
if (!fullscreen) return null;
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.macButtons, !active && styles.macButtonsInactive)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macClose)}
|
||||
aria-label={t("window.caption.close")}
|
||||
onClick={closeWindow}
|
||||
>
|
||||
<Icon name="mac-window-close" size={8} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macMinimize)}
|
||||
aria-label={t("window.caption.minimize")}
|
||||
disabled
|
||||
>
|
||||
<Icon name="mac-window-minimize" size={8} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macZoom)}
|
||||
aria-label={t("window.caption.exitFullScreen")}
|
||||
onClick={() => void commands.windowToggleFullscreen()}
|
||||
>
|
||||
<Icon name="mac-window-exit-full-screen" size={8} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
/* Keep the gear on the icon-only right stripe's centre line, including compact mode. */
|
||||
padding-right: calc(var(--stripe-outer-padding) + (var(--stripe-icon-button) - var(--titlebar-tool-button)) / 2);
|
||||
}
|
||||
/* Native full screen moves the window buttons out of the bar. */
|
||||
.overlayFullscreen {
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Welcome window: plain title on the frame colour with a hairline, like IntelliJ's welcome frame. */
|
||||
.minimal {
|
||||
background: var(--bg-elev);
|
||||
@@ -195,52 +199,3 @@
|
||||
background: var(--caption-close-pressed);
|
||||
color: var(--fg-caption-close-hover);
|
||||
}
|
||||
|
||||
/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */
|
||||
.macButtons {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: var(--h-titlebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 12px;
|
||||
zoom: calc(1 / var(--titlebar-zoom, 1));
|
||||
z-index: 1;
|
||||
}
|
||||
.macButton {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mac-window-glyph);
|
||||
box-shadow: inset 0 0 0 0.5px var(--mac-window-edge);
|
||||
}
|
||||
.macButton > * {
|
||||
opacity: 0;
|
||||
}
|
||||
.macButtons:hover .macButton:not(:disabled) > * {
|
||||
opacity: 1;
|
||||
}
|
||||
.macClose {
|
||||
background: var(--mac-window-close);
|
||||
}
|
||||
.macMinimize {
|
||||
background: var(--mac-window-minimize);
|
||||
}
|
||||
.macZoom {
|
||||
background: var(--mac-window-zoom);
|
||||
}
|
||||
.macButton:active:not(:disabled) {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */
|
||||
.macButton:disabled,
|
||||
.macButtonsInactive:not(:hover) .macButton {
|
||||
background: var(--mac-window-inactive);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
widgetTitle,
|
||||
} from "./mainToolbar";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { MacWindowButtons } from "./MacWindowButtons";
|
||||
import { useFullscreen } from "./useFullscreen";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { projectInitials } from "./projectColor";
|
||||
import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch";
|
||||
@@ -291,18 +291,23 @@ export function TitleBar({
|
||||
title?: string;
|
||||
}) {
|
||||
const overlay = usePlatform((s) => s.overlayTitleBar);
|
||||
const fullscreen = useFullscreen();
|
||||
const frameless = usePlatform((s) => s.framelessTitleBar);
|
||||
const { map, shown, menu } = useToolbar();
|
||||
const [menuExpanded, setMenuExpanded] = useState(false);
|
||||
const [bar, setBar] = useState<HTMLElement | null>(null);
|
||||
useNativeTitlebar(bar);
|
||||
const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless);
|
||||
const className = clsx(
|
||||
styles.bar,
|
||||
overlay && styles.overlay,
|
||||
overlay && fullscreen && styles.overlayFullscreen,
|
||||
frameless && styles.frameless,
|
||||
);
|
||||
// Distraction Free Mode: the window frame only. Where the title bar carries the
|
||||
// main menu (Windows, Linux) it stays, or nothing would lead back out of the mode.
|
||||
if (bare)
|
||||
return (
|
||||
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
|
||||
{overlay && <MacWindowButtons />}
|
||||
{frameless && (
|
||||
<>
|
||||
<MainMenu />
|
||||
@@ -325,7 +330,6 @@ export function TitleBar({
|
||||
showContextMenu(e, menu);
|
||||
}}
|
||||
>
|
||||
{overlay && <MacWindowButtons />}
|
||||
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
|
||||
{title && (
|
||||
<span className={styles.windowTitle} {...windowDragRegion}>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Whether this window fills the screen: native full screen, or a project tab shown in its
|
||||
* group's full-screen Space. Native full screen moves the window buttons out of the title bar,
|
||||
* so the room kept for them is released.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
|
||||
export function useFullscreen(): boolean {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
useEffect(() => {
|
||||
// The plain-browser shell and tests have no Tauri window to ask.
|
||||
if (!isTauri()) return;
|
||||
let disposed = false;
|
||||
const sync = () =>
|
||||
void commands
|
||||
.windowIsFullscreen()
|
||||
.then((on) => !disposed && setFullscreen(on))
|
||||
.catch(() => {});
|
||||
sync();
|
||||
const off = onIdeEvent("windowFullscreenChanged", sync);
|
||||
return () => {
|
||||
disposed = true;
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
return fullscreen;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача