[go: nahoru, domu]

blob: 20c3ea752fa78e82d93e06158c054123c656c367 [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/remote_cocoa/app_shim/immersive_mode_controller.h"
#include "base/apple/foundation_util.h"
#include "base/auto_reset.h"
#include "base/check.h"
#include "base/containers/contains.h"
#import "components/remote_cocoa/app_shim/immersive_mode_delegate_mac.h"
#import "components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h"
#include "ui/gfx/geometry/rect.h"
namespace {
const double kMinHeight = 0.5;
NSView* GetNSTitlebarContainerViewFromWindow(NSWindow* window) {
for (NSView* view in window.contentView.subviews) {
if ([view isKindOfClass:NSClassFromString(@"NSTitlebarContainerView")]) {
return view;
}
}
return nil;
}
} // namespace
@interface ImmersiveModeTitlebarObserver () {
base::WeakPtr<remote_cocoa::ImmersiveModeController> _controller;
NSView* __weak _titlebarContainerView;
}
@end
@implementation ImmersiveModeTitlebarObserver
- (instancetype)initWithController:
(base::WeakPtr<remote_cocoa::ImmersiveModeController>)
controller
titlebarContainerView:(NSView*)titlebarContainerView {
self = [super init];
if (self) {
_controller = std::move(controller);
_titlebarContainerView = titlebarContainerView;
[_titlebarContainerView addObserver:self
forKeyPath:@"frame"
options:NSKeyValueObservingOptionInitial |
NSKeyValueObservingOptionNew
context:nullptr];
}
return self;
}
- (void)dealloc {
[_titlebarContainerView removeObserver:self forKeyPath:@"frame"];
}
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id>*)change
context:(void*)context {
if (!_controller || ![keyPath isEqualToString:@"frame"]) {
return;
}
NSRect frame = [change[@"new"] rectValue];
_controller->OnTitlebarFrameDidChange(frame);
}
@end
// A stub NSWindowDelegate class that will be used to map the AppKit controlled
// NSWindow to the overlay view widget's NSWindow. The delegate will be used to
// help with input routing.
@interface ImmersiveModeMapper : NSObject <ImmersiveModeDelegate>
@property(weak) NSWindow* originalHostingWindow;
@end
@implementation ImmersiveModeMapper
@synthesize originalHostingWindow = _originalHostingWindow;
@end
// Host of the overlay view.
@interface ImmersiveModeTitlebarViewController
: NSTitlebarAccessoryViewController {
NSView* __strong _blank_separator_view;
}
@end
@implementation ImmersiveModeTitlebarViewController
- (instancetype)init {
if ((self = [super init])) {
_blank_separator_view = [[NSView alloc] init];
}
return self;
}
- (void)viewWillAppear {
[super viewWillAppear];
// Sometimes AppKit incorrectly positions NSToolbarFullScreenWindow entirely
// offscreen (particularly when this is a out-of-process app shim). Toggling
// visibility when appearing in the right window seems to fix the positioning.
// Only toggle the visibility if fullScreenMinHeight is not zero though, as
// triggering the repositioning when the toolbar is set to auto hide would
// result in it being incorrectly positioned in that case.
if (remote_cocoa::IsNSToolbarFullScreenWindow(self.view.window) &&
self.fullScreenMinHeight > kMinHeight && !self.hidden) {
self.hidden = YES;
self.hidden = NO;
}
}
// This is a private API method that will be used on macOS 11+.
// Remove a small 1px blur between the overlay view and tabbed overlay view by
// returning a blank NSView.
- (NSView*)separatorView {
return _blank_separator_view;
}
@end
// An NSView that will set the ImmersiveModeDelegate on the AppKit created
// window that ends up hosting this view via the
// NSTitlebarAccessoryViewController API.
@interface ImmersiveModeView : NSView
- (instancetype)initWithController:
(base::WeakPtr<remote_cocoa::ImmersiveModeController>)controller;
@end
@implementation ImmersiveModeView {
base::WeakPtr<remote_cocoa::ImmersiveModeController> _controller;
}
- (instancetype)initWithController:
(base::WeakPtr<remote_cocoa::ImmersiveModeController>)controller {
self = [super init];
if (self) {
_controller = std::move(controller);
}
return self;
}
- (void)viewWillMoveToWindow:(NSWindow*)window {
if (_controller) {
_controller->ImmersiveModeViewWillMoveToWindow(window);
}
}
@end
namespace remote_cocoa {
bool IsNSToolbarFullScreenWindow(NSWindow* window) {
// TODO(bur): Investigate other approaches to detecting
// NSToolbarFullScreenWindow. This is a private class and the name could
// change.
return [window isKindOfClass:NSClassFromString(@"NSToolbarFullScreenWindow")];
}
ImmersiveModeController::ImmersiveModeController(
NativeWidgetMacNSWindow* browser_window,
NativeWidgetMacNSWindow* overlay_window)
: weak_ptr_factory_(this) {
browser_window_ = browser_window;
overlay_window_ = overlay_window;
overlay_window_.commandDispatchParentOverride = browser_window_;
// A style of NSTitlebarSeparatorStyleAutomatic (default) will show a black
// line separator when removing the NSWindowStyleMaskFullSizeContentView style
// bit. We do not want a separator. Pre-macOS 11 there is no titlebar
// separator.
if (@available(macOS 11.0, *)) {
browser_window_.titlebarSeparatorStyle = NSTitlebarSeparatorStyleNone;
}
// Create a new NSTitlebarAccessoryViewController that will host the
// overlay_view_.
immersive_mode_titlebar_view_controller_ =
[[ImmersiveModeTitlebarViewController alloc] init];
// Create a NSWindow delegate that will be used to map the AppKit created
// NSWindow to the overlay view widget's NSWindow.
immersive_mode_mapper_ = [[ImmersiveModeMapper alloc] init];
immersive_mode_mapper_.originalHostingWindow = overlay_window_;
immersive_mode_titlebar_view_controller_.view = [[ImmersiveModeView alloc]
initWithController:weak_ptr_factory_.GetWeakPtr()];
// Remove the content view from the overlay view widget's NSWindow, and hold a
// local strong reference. This view will be re-parented into the AppKit
// created NSWindow.
BridgedContentView* overlay_content_view =
base::apple::ObjCCastStrict<BridgedContentView>(
overlay_window_.contentView);
overlay_content_view_ = overlay_content_view;
[overlay_content_view removeFromSuperview];
// The original content view (top chrome) has been moved to the AppKit
// created NSWindow. Create a new content view but reuse the original bridge
// so that mouse drags are handled.
overlay_window_.contentView =
[[BridgedContentView alloc] initWithBridge:overlay_content_view.bridge
bounds:gfx::Rect()];
// The overlay window will become a child of NSToolbarFullScreenWindow and sit
// above it in the z-order. Allow mouse events that are not handled by the
// BridgedContentView to passthrough the overlay window to the
// NSToolbarFullScreenWindow. This will allow the NSToolbarFullScreenWindow to
// become key when interacting with "top chrome".
overlay_window_.ignoresMouseEvents = YES;
// Add the overlay view to the accessory view controller getting ready to
// hand everything over to AppKit.
[immersive_mode_titlebar_view_controller_.view
addSubview:overlay_content_view];
immersive_mode_titlebar_view_controller_.layoutAttribute =
NSLayoutAttributeBottom;
}
ImmersiveModeController::~ImmersiveModeController() {
// Remove the titlebar observer before moving the view.
immersive_mode_titlebar_observer_ = nil;
overlay_window_.commandDispatchParentOverride = nil;
StopObservingChildWindows(overlay_window_);
// Rollback the view shuffling from enablement.
[overlay_content_view_ removeFromSuperview];
overlay_window_.contentView = overlay_content_view_;
[immersive_mode_titlebar_view_controller_ removeFromParentViewController];
browser_window_.styleMask |= NSWindowStyleMaskFullSizeContentView;
if (@available(macOS 11.0, *)) {
browser_window_.titlebarSeparatorStyle = NSTitlebarSeparatorStyleAutomatic;
}
}
void ImmersiveModeController::Enable() {
DCHECK(!enabled_);
enabled_ = true;
[browser_window_ addTitlebarAccessoryViewController:
immersive_mode_titlebar_view_controller_];
// Keep the overlay content view's size in sync with its parent view.
overlay_content_view_.translatesAutoresizingMaskIntoConstraints = NO;
[overlay_content_view_.heightAnchor
constraintEqualToAnchor:overlay_content_view_.superview.heightAnchor]
.active = YES;
[overlay_content_view_.widthAnchor
constraintEqualToAnchor:overlay_content_view_.superview.widthAnchor]
.active = YES;
[overlay_content_view_.centerXAnchor
constraintEqualToAnchor:overlay_content_view_.superview.centerXAnchor]
.active = YES;
[overlay_content_view_.centerYAnchor
constraintEqualToAnchor:overlay_content_view_.superview.centerYAnchor]
.active = YES;
}
void ImmersiveModeController::FullscreenTransitionCompleted() {
fullscreen_transition_complete_ = true;
UpdateToolbarVisibility(last_used_style_);
// Establish reveal locks for windows that exist before entering fullscreen,
// such as permission popups and the find bar. Do this after the fullscreen
// transition has ended to avoid graphical flashes during the animation.
for (NSWindow* child in overlay_window_.childWindows) {
if (!ShouldObserveChildWindow(child)) {
continue;
}
OnChildWindowAdded(child);
}
// Watch for child windows. When they are added the overlay view will be
// revealed as appropriate.
ObserveChildWindows(overlay_window_);
}
void ImmersiveModeController::OnTopViewBoundsChanged(const gfx::Rect& bounds) {
// Set the height of the AppKit fullscreen view. The width will be
// automatically handled by AppKit.
NSRect frame = NSRectFromCGRect(bounds.ToCGRect());
NSView* overlay_view = immersive_mode_titlebar_view_controller_.view;
NSSize size = overlay_view.window.frame.size;
if (frame.size.height != size.height) {
size.height = frame.size.height;
[overlay_view setFrameSize:size];
}
UpdateToolbarVisibility(last_used_style_);
// If the toolbar is always visible, update the fullscreen min height.
// Also update the fullscreen min height if the toolbar auto hides, but only
// if the toolbar is currently revealed.
if (last_used_style_ == mojom::ToolbarVisibilityStyle::kAlways ||
(last_used_style_ == mojom::ToolbarVisibilityStyle::kAutohide &&
reveal_lock_count_ > 0)) {
immersive_mode_titlebar_view_controller_.fullScreenMinHeight =
frame.size.height;
}
}
void ImmersiveModeController::UpdateToolbarVisibility(
mojom::ToolbarVisibilityStyle style) {
// Remember the last used style for internal use of UpdateToolbarVisibility.
last_used_style_ = style;
// Only make changes if there are no outstanding reveal locks.
if (reveal_lock_count_ > 0) {
return;
}
switch (style) {
case mojom::ToolbarVisibilityStyle::kAlways:
immersive_mode_titlebar_view_controller_.fullScreenMinHeight =
immersive_mode_titlebar_view_controller_.view.frame.size.height;
// Top chrome is removed from the content view when the browser window
// starts the fullscreen transition, however the request is asynchronous
// and sometimes top chrome is still present in the content view when the
// animation starts. This results in top chrome being painted twice during
// the fullscreen animation, once in the content view and once in
// `immersive_mode_titlebar_view_controller_`. Keep
// NSWindowStyleMaskFullSizeContentView active during the fullscreen
// transition to allow
// `immersive_mode_titlebar_view_controller_` to be
// displayed z-order on top of the content view. This will cover up any
// perceived jank.
// TODO(https://crbug.com/1375995): Handle fullscreen exit.
if (fullscreen_transition_complete_) {
browser_window_.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
} else {
browser_window_.styleMask |= NSWindowStyleMaskFullSizeContentView;
}
// Toggling the controller will allow the content view to resize below Top
// Chrome.
immersive_mode_titlebar_view_controller_.hidden = YES;
immersive_mode_titlebar_view_controller_.hidden = NO;
break;
case mojom::ToolbarVisibilityStyle::kAutohide:
immersive_mode_titlebar_view_controller_.hidden = NO;
// Workaround for https://crbug.com/1369643
immersive_mode_titlebar_view_controller_.fullScreenMinHeight = kMinHeight;
browser_window_.styleMask |= NSWindowStyleMaskFullSizeContentView;
break;
case mojom::ToolbarVisibilityStyle::kNone:
immersive_mode_titlebar_view_controller_.hidden = YES;
break;
}
}
void ImmersiveModeController::ObserveChildWindows(NSWindow* window) {
// Watch the Widget for addition and removal of child Widgets.
NativeWidgetMacNSWindow* widget_window =
base::apple::ObjCCastStrict<NativeWidgetMacNSWindow>(window);
widget_window.childWindowAddedHandler = ^(NSWindow* child) {
OnChildWindowAdded(child);
};
widget_window.childWindowRemovedHandler = ^(NSWindow* child) {
OnChildWindowRemoved(child);
};
}
void ImmersiveModeController::StopObservingChildWindows(NSWindow* window) {
NativeWidgetMacNSWindow* widget_window =
base::apple::ObjCCastStrict<NativeWidgetMacNSWindow>(window);
widget_window.childWindowAddedHandler = nil;
widget_window.childWindowRemovedHandler = nil;
}
bool ImmersiveModeController::ShouldObserveChildWindow(NSWindow* child) {
return true;
}
NSWindow* ImmersiveModeController::browser_window() {
return browser_window_;
}
NSWindow* ImmersiveModeController::overlay_window() {
return overlay_window_;
}
BridgedContentView* ImmersiveModeController::overlay_content_view() {
return overlay_content_view_;
}
void ImmersiveModeController::OnChildWindowAdded(NSWindow* child) {
// When windows are re-ordered they get removed and re-added triggering
// OnChildWindowRemoved and OnChildWindowAdded calls.
// Prevent any given window from obtaining more than one lock.
if (!base::Contains(window_lock_received_, child)) {
window_lock_received_.insert(child);
RevealLock();
}
// TODO(https://crbug.com/1350595): Handle a detached find bar.
}
void ImmersiveModeController::OnChildWindowRemoved(NSWindow* child) {
if (base::Contains(window_lock_received_, child)) {
window_lock_received_.erase(child);
RevealUnlock();
}
}
void ImmersiveModeController::RevealLock() {
reveal_lock_count_++;
immersive_mode_titlebar_view_controller_.fullScreenMinHeight =
immersive_mode_titlebar_view_controller_.view.frame.size.height;
}
void ImmersiveModeController::RevealUnlock() {
// Re-hide the toolbar if appropriate.
if (--reveal_lock_count_ < 1 &&
immersive_mode_titlebar_view_controller_.fullScreenMinHeight > 0 &&
last_used_style_ == mojom::ToolbarVisibilityStyle::kAutohide) {
immersive_mode_titlebar_view_controller_.fullScreenMinHeight = 0;
}
// Account for last_used_style_ changing while a reveal lock was active.
if (reveal_lock_count_ < 1) {
UpdateToolbarVisibility(last_used_style_);
}
DCHECK(reveal_lock_count_ >= 0);
}
void ImmersiveModeController::ImmersiveModeViewWillMoveToWindow(
NSWindow* window) {
// AppKit hands this view controller over to a fullscreen transition window
// before we finally land at the NSToolbarFullScreenWindow. Add the frame
// observer only once we reach the NSToolbarFullScreenWindow.
if (remote_cocoa::IsNSToolbarFullScreenWindow(window)) {
// This window is created by AppKit. Make sure it doesn't have a delegate
// so we can use it for out own purposes.
DCHECK(!window.delegate);
window.delegate = immersive_mode_mapper_;
// Attach overlay_widget to NSToolbarFullScreen so that children are placed
// on top of the toolbar. When exiting fullscreen, we don't re-parent the
// overlay window back to the browser window because it seems to trigger
// re-entrancy in AppKit and cause crash. This is safe because sub-widgets
// will be re-parented to the browser window and therefore the overlay
// window won't have any observable effect.
// Also, explicitly remove the overlay window from the browser window.
// Leaving a dangling reference to the overlay window on the browser window
// causes odd behavior.
[browser_window_ removeChildWindow:overlay_window()];
[window addChildWindow:overlay_window() ordered:NSWindowAbove];
NSView* view = GetNSTitlebarContainerViewFromWindow(window);
DCHECK(view);
// Create the titlebar observer. Observing can only start once the view has
// been fully re-parented into the AppKit fullscreen window.
immersive_mode_titlebar_observer_ = [[ImmersiveModeTitlebarObserver alloc]
initWithController:weak_ptr_factory_.GetWeakPtr()
titlebarContainerView:view];
}
}
void ImmersiveModeController::OnTitlebarFrameDidChange(NSRect frame) {
LayoutWindowWithAnchorView(overlay_window_, overlay_content_view_);
}
bool ImmersiveModeController::IsTabbed() {
return false;
}
double ImmersiveModeController::GetOffscreenYOrigin() {
// Get the height of the screen. Using this as the y origin will move a window
// offscreen.
double y = browser_window_.screen.frame.size.height;
// Make sure to make it past the safe area insets, otherwise some portion
// of the window may still be displayed.
if (@available(macOS 12.0, *)) {
y += browser_window_.screen.safeAreaInsets.top;
}
return y;
}
void ImmersiveModeController::LayoutWindowWithAnchorView(NSWindow* window,
NSView* anchor_view) {
// Find the anchor view's point on screen (bottom left).
NSPoint point_in_window = [anchor_view convertPoint:NSZeroPoint toView:nil];
NSPoint point_on_screen =
[anchor_view.window convertPointToScreen:point_in_window];
// This branch is only useful on macOS 11 and greater. macOS 10.15 and
// earlier move the window instead of clipping the view within the window.
// This allows the overlay window to appropriately track the overlay view.
if (@available(macOS 11.0, *)) {
// If the anchor view is clipped move the window off screen. A clipped
// anchor view indicates the titlebar is hidden or is in transition AND the
// browser content view takes up the whole window
// ("Always Show Toolbar in Full Screen" is disabled). When we are in this
// state we don't want the window on screen, otherwise it may mask input to
// the browser view. In all other cases will not enter this branch and the
// window will be placed at the same coordinates as the anchor view.
if (anchor_view.visibleRect.size.height != anchor_view.frame.size.height) {
point_on_screen.y = GetOffscreenYOrigin();
}
}
// If the toolbar is hidden (mojom::ToolbarVisibilityStyle::kNone) also move
// the window offscreen. This applies to all versions of macOS where Chrome
// can be run.
if (last_used_style_ == mojom::ToolbarVisibilityStyle::kNone) {
point_on_screen.y = GetOffscreenYOrigin();
}
[window setFrameOrigin:point_on_screen];
}
} // namespace remote_cocoa