blob: 8445bace8baff102eaa04c1be6f4e85ccd6ff76b [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/auto_reset.h"
#include "base/check.h"
#include "base/mac/foundation_util.h"
#include "base/mac/scoped_block.h"
#import "components/remote_cocoa/app_shim/immersive_mode_delegate_mac.h"
#import "components/remote_cocoa/app_shim/native_widget_mac_nswindow.h"
#import "components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h"
#include "ui/gfx/geometry/rect.h"
namespace {
const double kThinControllerHeight = 0.5;
// TODO(https://crbug.com/1373552): use constraints / autoresizingmask instead
// of manually setting the frame size.
void PropagateFrameSizeToViewsSubviews(NSView* view) {
for (NSView* sub_view in view.subviews) {
if ([sub_view isKindOfClass:[BridgedContentView class]]) {
[sub_view setFrameSize:view.frame.size];
}
}
}
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* _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:NULL];
}
return self;
}
- (void)dealloc {
[_titlebarContainerView removeObserver:self forKeyPath:@"frame"];
[super dealloc];
}
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id>*)change
context:(void*)context {
if (![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(assign) NSWindow* originalHostingWindow;
@end
@implementation ImmersiveModeMapper
@synthesize originalHostingWindow = _originalHostingWindow;
@end
// Host of the overlay view.
@interface ImmersiveModeTitlebarViewController
: NSTitlebarAccessoryViewController {
base::OnceClosure _view_will_appear_callback;
}
@end
@implementation ImmersiveModeTitlebarViewController
- (instancetype)initWithViewWillAppearCallback:
(base::OnceClosure)view_will_appear_callback {
if ((self = [super init])) {
_view_will_appear_callback = std::move(view_will_appear_callback);
}
return self;
}
- (void)viewWillAppear {
[super viewWillAppear];
// Resize the views and run the callback on the first call to this method. We
// will most likely be in the fullscreen transition window and we want our
// views to be displayed.
PropagateFrameSizeToViewsSubviews(self.view);
if (!_view_will_appear_callback.is_null()) {
// Triggers Views to display top chrome.
std::move(_view_will_appear_callback).Run();
}
// 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 != 0 && !self.hidden) {
self.hidden = YES;
self.hidden = NO;
}
}
@end
@interface ClearTitlebarViewController : NSTitlebarAccessoryViewController {
CGFloat _height;
}
@end
@implementation ClearTitlebarViewController
- (instancetype)initWithHeight:(CGFloat)height {
self = [super init];
if (self) {
_height = height;
}
return self;
}
- (void)viewWillAppear {
[super viewWillAppear];
NSSize size = self.view.frame.size;
size.height = _height;
[self.view setFrameSize:size];
// Hide the controller before it is appears but after the view's frame is
// set. This will extend the NSTitlebarAccessoryViewController mouse
// tracking area over the entirety of the window stopping the titlebar from
// auto hiding.
self.hidden = YES;
}
@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
@interface ImmersiveModeWindowObserver : NSObject {
base::WeakPtr<remote_cocoa::ImmersiveModeController> _controller;
}
- (instancetype)initWithController:
(base::WeakPtr<remote_cocoa::ImmersiveModeController>)controller;
@end
@implementation ImmersiveModeWindowObserver
- (instancetype)initWithController:
(base::WeakPtr<remote_cocoa::ImmersiveModeController>)controller {
self = [super init];
if (self) {
_controller = std::move(controller);
}
return self;
}
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id>*)change
context:(void*)context {
if (![keyPath isEqualToString:@"visible"]) {
return;
}
BOOL visible = [change[NSKeyValueChangeNewKey] boolValue];
NSWindow* window = base::mac::ObjCCastStrict<NSWindow>(object);
if (visible) {
if (_controller) {
_controller->TitlebarLock();
}
return;
}
// Assume not-visible is a terminal state for an overlay child window. Also
// assume child windows will become not-visible before self is destroyed.
// These assumptions makes adding and removing the visible observer trival.
[window removeObserver:self forKeyPath:@"visible"];
if (_controller) {
_controller->TitlebarUnlock();
}
}
@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(NSWindow* browser_window,
NSWindow* overlay_window,
base::OnceClosure callback)
: browser_window_(browser_window),
overlay_window_(overlay_window),
weak_ptr_factory_(this) {
immersive_mode_window_observer_.reset([[ImmersiveModeWindowObserver alloc]
initWithController:weak_ptr_factory_.GetWeakPtr()]);
// 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_.reset(
[[ImmersiveModeTitlebarViewController alloc]
initWithViewWillAppearCallback:std::move(callback)]);
// Create a NSWindow delegate that will be used to map the AppKit created
// NSWindow to the overlay view widget's NSWindow.
immersive_mode_mapper_.reset([[ImmersiveModeMapper alloc] init]);
immersive_mode_mapper_.get().originalHostingWindow = overlay_window_;
immersive_mode_titlebar_view_controller_.get().view =
[[ImmersiveModeView alloc]
initWithController:weak_ptr_factory_.GetWeakPtr()];
// Remove the content view from the overlay view widget's NSWindow. This
// view will be re-parented into the AppKit created NSWindow.
overlay_content_view_ = base::mac::ObjCCastStrict<BridgedContentView>(
overlay_window_.contentView);
[overlay_content_view_ retain];
[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()] autorelease];
// 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_.get().view
addSubview:overlay_content_view_];
[overlay_content_view_ release];
immersive_mode_titlebar_view_controller_.get().layoutAttribute =
NSLayoutAttributeBottom;
thin_titlebar_view_controller_.reset(
[[NSTitlebarAccessoryViewController alloc] init]);
thin_titlebar_view_controller_.get().view =
[[[NSView alloc] init] autorelease];
thin_titlebar_view_controller_.get().view.wantsLayer = YES;
thin_titlebar_view_controller_.get().view.layer.backgroundColor =
NSColor.blackColor.CGColor;
thin_titlebar_view_controller_.get().layoutAttribute =
NSLayoutAttributeBottom;
thin_titlebar_view_controller_.get().fullScreenMinHeight =
kThinControllerHeight;
}
ImmersiveModeController::~ImmersiveModeController() {
// Remove the titlebar observer before moving the view.
immersive_mode_titlebar_observer_.reset();
// Rollback the view shuffling from enablement.
[thin_titlebar_view_controller_ removeFromParentViewController];
[overlay_content_view_ removeFromSuperview];
overlay_window_.contentView = overlay_content_view_;
[immersive_mode_titlebar_view_controller_ removeFromParentViewController];
[immersive_mode_titlebar_view_controller_.get().view release];
immersive_mode_titlebar_view_controller_.reset();
browser_window_.styleMask |= NSWindowStyleMaskFullSizeContentView;
if (@available(macOS 11.0, *)) {
browser_window_.titlebarSeparatorStyle = NSTitlebarSeparatorStyleAutomatic;
}
// Move sub-widgets back to the browser widget.
ReparentChildWindows(overlay_window_, browser_window_);
}
void ImmersiveModeController::Enable() {
DCHECK(!enabled_);
enabled_ = true;
immersive_mode_titlebar_view_controller_.get().hidden = YES;
[browser_window_ addTitlebarAccessoryViewController:
immersive_mode_titlebar_view_controller_];
// Move sub-widgets from the browser widget to the overlay widget so that
// they are rendered above the toolbar.
ObserveOverlayChildWindows();
ReparentChildWindows(browser_window_, overlay_window_);
thin_titlebar_view_controller_.get().hidden = YES;
[browser_window_
addTitlebarAccessoryViewController:thin_titlebar_view_controller_];
NSRect frame = thin_titlebar_view_controller_.get().view.frame;
frame.size.height = kThinControllerHeight;
thin_titlebar_view_controller_.get().view.frame = frame;
}
void ImmersiveModeController::FullscreenTransitionCompleted() {
fullscreen_transition_complete_ = true;
UpdateToolbarVisibility(last_used_style_);
}
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_.get().view;
NSSize size = overlay_view.frame.size;
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_.get().fullScreenMinHeight =
immersive_mode_titlebar_view_controller_.get().view.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 (!fullscreen_transition_complete_ || titlebar_lock_count_ > 0 ||
reveal_lock_count_ > 0) {
return;
}
switch (style) {
case mojom::ToolbarVisibilityStyle::kAlways:
immersive_mode_titlebar_view_controller_.get().fullScreenMinHeight =
immersive_mode_titlebar_view_controller_.get().view.frame.size.height;
thin_titlebar_view_controller_.get().hidden = YES;
browser_window_.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
// Toggling the controller will allow the content view to resize below Top
// Chrome.
immersive_mode_titlebar_view_controller_.get().hidden = YES;
immersive_mode_titlebar_view_controller_.get().hidden = NO;
break;
case mojom::ToolbarVisibilityStyle::kAutohide:
immersive_mode_titlebar_view_controller_.get().hidden = NO;
// The thin titlebar controller keeps a tiny portion of the AppKit
// fullscreen NSWindow on screen as a workaround for
// https://crbug.com/1369643.
thin_titlebar_view_controller_.get().hidden = NO;
immersive_mode_titlebar_view_controller_.get().fullScreenMinHeight = 0;
browser_window_.styleMask |= NSWindowStyleMaskFullSizeContentView;
break;
case mojom::ToolbarVisibilityStyle::kNone:
thin_titlebar_view_controller_.get().hidden = YES;
immersive_mode_titlebar_view_controller_.get().hidden = YES;
break;
}
// Unpin the titlebar.
SetTitlebarPinned(false);
}
// This function will pin or unpin the titlebar (holder of the traffic
// lights). When the titlebar is pinned the titlebar will stay present on
// screen even if the mouse leaves the titlebar or Toolbar area. This is
// helpful when displaying sub-widgets. When the titlebar is not pinned it
// will reveal and auto-hide itself based on mouse movement (controlled by
// AppKit).
void ImmersiveModeController::SetTitlebarPinned(bool pinned) {
// Remove current, if any, clear controllers from the window. For some reason
// -removeFromParentViewController does not always remove the controller.
// Attempt to remove the current and any stale controllers.
for (NSTitlebarAccessoryViewController* c in browser_window_
.titlebarAccessoryViewControllers) {
if ([c isKindOfClass:[ClearTitlebarViewController class]]) {
[c removeFromParentViewController];
}
}
if (!pinned) {
clear_titlebar_view_controller_.reset();
return;
}
clear_titlebar_view_controller_.reset([[ClearTitlebarViewController alloc]
initWithHeight:browser_window_.contentView.frame.size.height -
kThinControllerHeight]);
clear_titlebar_view_controller_.get().view =
[[[NSView alloc] init] autorelease];
clear_titlebar_view_controller_.get().layoutAttribute =
NSLayoutAttributeBottom;
[browser_window_
addTitlebarAccessoryViewController:clear_titlebar_view_controller_];
}
void ImmersiveModeController::ObserveOverlayChildWindows() {
// Watch the overlay Widget for new child Widgets.
NativeWidgetMacNSWindow* overlay_window =
base::mac::ObjCCastStrict<NativeWidgetMacNSWindow>(overlay_window_);
overlay_window.childWindowAddedHandler = ^(NSWindow* child) {
OnChildWindowAdded(child);
};
}
void ImmersiveModeController::OnChildWindowAdded(NSWindow* child) {
// Ignore non-visible children.
if (!child.visible) {
return;
}
[child addObserver:immersive_mode_window_observer_
forKeyPath:@"visible"
options:NSKeyValueObservingOptionInitial |
NSKeyValueObservingOptionNew
context:nullptr];
}
void ImmersiveModeController::ReparentChildWindows(NSWindow* source,
NSWindow* target) {
NativeWidgetNSWindowBridge* source_bridge =
NativeWidgetNSWindowBridge::GetFromNativeWindow(source);
NativeWidgetNSWindowBridge* target_bridge =
NativeWidgetNSWindowBridge::GetFromNativeWindow(target);
// TODO(kerenzhu): DCHECK(source_bridge && target_bridge)
// Only in unittests the associated bridges might not exist.
if (source_bridge && target_bridge) {
source_bridge->MoveChildrenTo(target_bridge, /*anchored_only=*/true);
}
}
void ImmersiveModeController::TitlebarLock() {
titlebar_lock_count_++;
if (titlebar_fully_visible_) {
SetTitlebarPinned(true);
}
}
void ImmersiveModeController::TitlebarUnlock() {
if (--titlebar_lock_count_ < 1) {
SetTitlebarPinned(false);
}
DCHECK(titlebar_lock_count_ >= 0);
}
void ImmersiveModeController::RevealLock() {
reveal_lock_count_++;
immersive_mode_titlebar_view_controller_.get().fullScreenMinHeight =
immersive_mode_titlebar_view_controller_.get().view.frame.size.height;
}
void ImmersiveModeController::RevealUnlock() {
// Re-hide the toolbar if appropriate.
if (--reveal_lock_count_ < 1 &&
immersive_mode_titlebar_view_controller_.get().fullScreenMinHeight > 0 &&
last_used_style_ == mojom::ToolbarVisibilityStyle::kAutohide) {
immersive_mode_titlebar_view_controller_.get().fullScreenMinHeight = 0;
}
// Account for last_used_style_ changing to kAlways while a reveal lock was
// active.
if (reveal_lock_count_ < 1 &&
last_used_style_ == mojom::ToolbarVisibilityStyle::kAlways) {
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_.get();
// Attach overlay_widget to NSToolbarFullScreen so that children are placed
// on top of the toolbar. When exitting fullscreeen, 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.
[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_.reset(
[[ImmersiveModeTitlebarObserver alloc]
initWithController:weak_ptr_factory_.GetWeakPtr()
titlebarContainerView:view]);
}
}
void ImmersiveModeController::OnTitlebarFrameDidChange(NSRect frame) {
titlebar_fully_visible_ = frame.origin.y == 0;
// Find the overlay view's point on screen (bottom left).
NSPoint point_in_window = [overlay_content_view_ convertPoint:NSZeroPoint
toView:nil];
NSPoint point_on_screen =
[overlay_content_view_.window convertPointToScreen:point_in_window];
BOOL overlay_view_is_clipped = NO;
// 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 overlay view is clipped move the overlay window off screen. A
// clipped overlay 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 overlay window on screen, otherwise it may mask
// input to the browser view. In all other cases will not enter this
// branch and the overlay window will be placed at the same coordinates as
// the overlay view.
if (overlay_content_view_.visibleRect.size.height !=
overlay_content_view_.frame.size.height) {
point_on_screen.y = -overlay_content_view_.frame.size.height;
overlay_view_is_clipped = YES;
}
}
if (!overlay_view_is_clipped) {
// If there are sub-windows and the titlebar is fully visible (a y origin
// of 0), pin the titlebar. This will prevent the titlebar from autohiding
// and causing the sub-windows from moving up when the mouse leaves top
// chrome.
if (!titlebar_frame_change_barrier_ && titlebar_fully_visible_ &&
titlebar_lock_count() > 0) {
// Add a barrier to prevent re-entry, which is a byproduct of
// TitlebarLock() and TitlebarUnlock().
base::AutoReset<bool> set_barrier(&titlebar_frame_change_barrier_, YES);
// This lock / unlock scheme is to force the titlebar to be pinned in
// place, which can only be done when the titlebar is fully visible.
// Existing sub-windows hold a lock, however since the titlebar isn't
// fully revealed until this point the existing locks don't actually pin
// the titlebar. The existing locks are still important for knowing when
// to unpin the titlebar. When all outstanding locks are released the
// titlebar be unpinned.
TitlebarLock();
TitlebarUnlock();
}
}
[overlay_window_ setFrameOrigin:point_on_screen];
}
} // namespace remote_cocoa