AppKit: Implement window zoom and title bar double-click

Implement Cocoa zoom API on CPWindow and enable zoom button for resizable windows. Zoom window on double-click where drag moves it.

* CPWindow.j:
  - Add zoom:, performZoom: and isZoomed, toggling between standard frame and user frame; treat frame within 4 points of standard frame as zoomed.
  - Add windowWillUseStandardFrame:defaultFrame: and windowShouldZoom:toFrame: to CPWindowDelegate; consult window subclass when delegate lacks them.
  - Take default frame from platform window's usable content frame; keep standard frame within minimum and maximum sizes and usable area.
  - Enable performZoom: and zoom: menu items only when window can zoom.

* _CPStandardWindowView.j: Enable zoom button for resizable windows and target window's zoom:.

* _CPWindowView.j: Call performZoom: on double-click where drag moves window.

* CPWindowTest.j: Add tests for zoom toggling, default frame, delegate refusal, maximum size, missing user frame, window subclass standard frame, performZoom: on fixed-size window, menu validation and title bar double-click.

* DropInCompatibilityTest.j: Add test for zoom button enabled only for resizable windows.
This commit is contained in:
David Richardson committed 2026-11-08 14:17:08 -06:00
1 parent d55a620223
commit af4d9dd9b9
5 files changed
+392 -8

No files matched your search

+144 -5
View File
@@ -64,6 +64,8 @@
@optional
- (BOOL)windowShouldClose:(CPWindow)aWindow;
- (BOOL)windowShouldZoom:(CPWindow)aWindow toFrame:(CGRect)aNewFrame;
- (CGRect)windowWillUseStandardFrame:(CPWindow)aWindow defaultFrame:(CGRect)aNewFrame;
- (CGSize)windowWillResize:(CPWindow)sender toSize:(CGSize)aSize;
- (CPUndoManager)windowWillReturnUndoManager:(CPWindow)window;
- (void)windowDidBecomeKey:(CPNotification)aNotification;
@@ -84,7 +86,9 @@
var CPWindowDelegate_windowShouldClose_ = 1 << 1,
CPWindowDelegate_windowWillReturnUndoManager_ = 1 << 2,
CPWindowDelegate_windowWillClose_ = 1 << 3,
CPWindowDelegate_windowWillResize_toSize_ = 1 << 4;
CPWindowDelegate_windowWillResize_toSize_ = 1 << 4,
CPWindowDelegate_windowShouldZoom_toFrame_ = 1 << 5,
CPWindowDelegate_windowWillUseStandardFrame_defaultFrame_ = 1 << 6;
var CPWindowSaveImage = nil,
@@ -93,7 +97,13 @@ var CPWindowSaveImage = nil,
CPWindowResizeStyleGlobalChangeNotification = @"CPWindowResizeStyleGlobalChangeNotification",
CPWindowMinVisibleHorizontalMargin = 40,
CPWindowMinVisibleVerticalMargin = 2;
CPWindowMinVisibleVerticalMargin = 2,
/*
A window shows its standard frame when its frame lies within this many points of
it, as in Cocoa, where a frame within a few pixels of the standard frame matches.
*/
_CPWindowZoomTolerance = 4.0;
/*
Keys for which action messages will be sent by default when unhandled, e.g. complete:.
@@ -177,6 +187,7 @@ var CPWindowActionMessageKeys = [
BOOL _isVisible;
BOOL _hasBeenOrderedIn @accessors;
BOOL _isMiniaturized;
CGRect _userStateFrame;
BOOL _isExcludedFromWindowsMenu;
BOOL _isAnimating @accessors(setter=_setAnimating:);
BOOL _hasShadow;
@@ -1563,6 +1574,12 @@ CPTexturedBackgroundWindowMask
if ([_delegate respondsToSelector:@selector(windowWillResize:toSize:)])
_implementedDelegateMethods |= CPWindowDelegate_windowWillResize_toSize_;
if ([_delegate respondsToSelector:@selector(windowShouldZoom:toFrame:)])
_implementedDelegateMethods |= CPWindowDelegate_windowShouldZoom_toFrame_;
if ([_delegate respondsToSelector:@selector(windowWillUseStandardFrame:defaultFrame:)])
_implementedDelegateMethods |= CPWindowDelegate_windowWillUseStandardFrame_defaultFrame_;
if ([_delegate respondsToSelector:@selector(windowDidResignKey:)])
[defaultCenter
addObserver:_delegate
@@ -2531,6 +2548,92 @@ CPTexturedBackgroundWindowMask
return _isMiniaturized;
}
// Zooming Windows
/*!
Simulates the user clicking the zoom button. Zooms the window when its zoom button is
enabled, and does nothing otherwise. The zoom button is enabled when the window is
resizable and is neither a sheet nor a full platform window.
@param aSender the object making this request
*/
- (void)performZoom:(id)aSender
{
if ([self _canZoom])
[self zoom:aSender];
}
/*!
Toggles the window between its standard frame and its user frame.
The standard frame is the frame that the delegate returns from
\c -windowWillUseStandardFrame:defaultFrame:. The default frame passed to that method
is the usable area of the browser window. When neither the delegate nor the window
implements the method, the default frame is the standard frame. The standard frame
keeps within the window's minimum and maximum sizes and within the usable area.
When the window shows its standard frame, it returns to the user frame, which is the
frame it had before it last zoomed. Without a user frame, the window does not change.
When the window shows any other frame, it saves that frame as the user frame and takes
its standard frame. The delegate can refuse either change through
\c -windowShouldZoom:toFrame:.
@param aSender the object making this request
*/
- (void)zoom:(id)aSender
{
var standardFrame = [self _standardFrame],
isZoomed = [self _frameMatchesStandardFrame:standardFrame],
newFrame = isZoomed ? _userStateFrame : standardFrame;
if (!newFrame || ![self _sendDelegateWindowShouldZoomToFrame:newFrame])
return;
if (!isZoomed)
_userStateFrame = CGRectMakeCopy(_frame);
[self _setFrame:newFrame display:YES animate:NO constrainWidth:YES constrainHeight:YES];
}
/*!
Returns \c YES if the window shows its standard frame, as \c -zoom: determines it.
*/
- (BOOL)isZoomed
{
return [self _frameMatchesStandardFrame:[self _standardFrame]];
}
/*
The window can zoom when it shows an enabled zoom button.
*/
- (BOOL)_canZoom
{
return !!(_styleMask & CPResizableWindowMask) && !_isSheet && !_isFullPlatformWindow;
}
/*
Returns the frame that -zoom: gives the window in its standard state.
*/
- (CGRect)_standardFrame
{
var frame = CGRectMakeCopy([self _sendDelegateWindowWillUseStandardFrame:[_platformWindow usableContentFrame]]);
frame.size.width = MIN(MAX(frame.size.width, _minSize.width), _maxSize.width);
frame.size.height = MIN(MAX(frame.size.height, _minSize.height), _maxSize.height);
return [self _constrainFrame:frame toUsableScreenWidth:YES andHeight:YES];
}
/*
Returns YES if the window's frame lies within the zoom tolerance of aFrame. Setting a
frame rounds it to whole points, so a fractional standard frame never matches exactly.
*/
- (BOOL)_frameMatchesStandardFrame:(CGRect)aFrame
{
return ABS(CGRectGetMinX(_frame) - CGRectGetMinX(aFrame)) <= _CPWindowZoomTolerance &&
ABS(CGRectGetMinY(_frame) - CGRectGetMinY(aFrame)) <= _CPWindowZoomTolerance &&
ABS(CGRectGetWidth(_frame) - CGRectGetWidth(aFrame)) <= _CPWindowZoomTolerance &&
ABS(CGRectGetHeight(_frame) - CGRectGetHeight(aFrame)) <= _CPWindowZoomTolerance;
}
// Closing Windows
/*!
@@ -2848,8 +2951,9 @@ CPTexturedBackgroundWindowMask
For \c -toggleToolbarShown:, the item's title becomes "Hide Toolbar" or "Show Toolbar"
to reflect the toolbar's visibility, and the item is enabled only when the window has
a toolbar. \c -undo: and \c -redo: are enabled only when the window's undo manager has a
change to undo or redo. \c -performMiniaturize: and \c -performZoom: are disabled,
because the window can neither minimize nor zoom. Every other item is enabled.
change to undo or redo. \c -performMiniaturize: is disabled, because a page cannot
minimize its browser window. \c -performZoom: and \c -zoom: are enabled only when the
window's zoom button is enabled. Every other item is enabled.
@param anItem the item to validate
@return \c YES if the item should be enabled
*/
@@ -2867,9 +2971,12 @@ CPTexturedBackgroundWindowMask
The Minimize and Zoom items agree with the window's buttons. Minimize was enabled,
and left the window on screen but counted as minimized.
*/
if (action === @selector(performMiniaturize:) || action === @selector(performZoom:))
if (action === @selector(performMiniaturize:))
return NO;
if (action === @selector(performZoom:) || action === @selector(zoom:))
return [self _canZoom];
if (action !== @selector(toggleToolbarShown:))
return YES;
@@ -3766,6 +3873,38 @@ var keyViewComparator = function(lhs, rhs, context)
return [_delegate windowWillResize:self toSize:aSize];
}
/*!
@ignore
Call the delegate windowWillUseStandardFrame:defaultFrame:, or the window's own
implementation when the delegate has none, as in Cocoa.
*/
- (CGRect)_sendDelegateWindowWillUseStandardFrame:(CGRect)aFrame
{
if (_implementedDelegateMethods & CPWindowDelegate_windowWillUseStandardFrame_defaultFrame_)
return [_delegate windowWillUseStandardFrame:self defaultFrame:aFrame];
if ([self respondsToSelector:@selector(windowWillUseStandardFrame:defaultFrame:)])
return [self windowWillUseStandardFrame:self defaultFrame:aFrame];
return aFrame;
}
/*!
@ignore
Call the delegate windowShouldZoom:toFrame:, or the window's own implementation when
the delegate has none, as in Cocoa.
*/
- (BOOL)_sendDelegateWindowShouldZoomToFrame:(CGRect)aFrame
{
if (_implementedDelegateMethods & CPWindowDelegate_windowShouldZoom_toFrame_)
return [_delegate windowShouldZoom:self toFrame:aFrame];
if ([self respondsToSelector:@selector(windowShouldZoom:toFrame:)])
return [self windowShouldZoom:self toFrame:aFrame];
return YES;
}
@end
+6 -3
View File
@@ -210,8 +210,8 @@ var _CPStandardWindowViewDividerViewHeight = 1.0;
A standard titled window shows close, minimize and zoom, and enables each only
when it is available, as Apple's guidelines ask. Only the available buttons
were shown, so an unavailable control was hidden instead of disabled. Minimize
is never available, because a page cannot minimize its browser window. No
class implements zooming yet, so zoom is not available either.
is never available, because a page cannot minimize its browser window. Zoom
is available when the window is resizable, as in macOS.
*/
_closeButton = [self _windowButton];
_minimizeButton = [self _windowButton];
@@ -219,7 +219,7 @@ var _CPStandardWindowViewDividerViewHeight = 1.0;
[_closeButton setEnabled:!!(_styleMask & CPClosableWindowMask)];
[_minimizeButton setEnabled:NO];
[_zoomButton setEnabled:NO];
[_zoomButton setEnabled:!!(_styleMask & CPResizableWindowMask)];
[self _updateWindowButtons];
[self updateTrackingAreas];
@@ -251,6 +251,9 @@ var _CPStandardWindowViewDividerViewHeight = 1.0;
[_minimizeButton setTarget:[self window]];
[_minimizeButton setAction:@selector(performMiniaturize:)];
[_zoomButton setTarget:[self window]];
[_zoomButton setAction:@selector(zoom:)];
}
- (CGSize)toolbarOffset
+9
View File
@@ -260,7 +260,16 @@ _CPWindowViewResizeSlop = 3;
}
if ([self couldBeMoveEvent:anEvent])
{
/*
A double-click where a drag moves the window zooms it, as the default macOS
setting does. The second click began another move, so a double-click did nothing.
*/
if ([anEvent clickCount] === 2)
return [theWindow performZoom:self];
[self trackMoveWithEvent:anEvent];
}
else
[super mouseDown:anEvent];
}
+227
View File
@@ -1,4 +1,5 @@
@import <AppKit/CPWindow.j>
@import <AppKit/CPMenuItem.j>
@import <AppKit/_CPBorderlessWindowView.j>
@import <Foundation/CPURL.j>
@@ -136,4 +137,230 @@
[_window orderOut:self];
}
/*
The zoom tests supply the standard frame through the delegate. Outside a browser,
each window has its own platform window, whose usable area follows the window.
*/
- (CPWindow)resizableWindow
{
return [[CPWindow alloc] initWithContentRect:CGRectMake(100.0, 100.0, 400.0, 300.0)
styleMask:CPTitledWindowMask | CPResizableWindowMask];
}
- (void)assertWindow:(CPWindow)aWindow hasFrame:(CGRect)aFrame
{
[self assertTrue:CGRectEqualToRect([aWindow frame], aFrame)
message:@"expected " + CPStringFromRect(aFrame) + " but was " + CPStringFromRect([aWindow frame])];
}
- (void)testZoomTogglesBetweenTheStandardFrameAndTheUserFrame
{
var window = [self resizableWindow],
standardFrame = CGRectMake(20.0, 30.0, 640.0, 480.0),
delegate = [[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame],
userFrame = [window frame];
[window setDelegate:delegate];
[window zoom:self];
[self assertWindow:window hasFrame:standardFrame];
[self assertTrue:[window isZoomed]];
[window zoom:self];
[self assertWindow:window hasFrame:userFrame];
[self assertFalse:[window isZoomed]];
}
- (void)testZoomPassesTheUsableAreaAsTheDefaultFrame
{
var window = [self resizableWindow],
delegate = [[CPWindowTestZoomDelegate alloc] initWithStandardFrame:CGRectMake(20.0, 30.0, 640.0, 480.0)],
usableFrame = [[window platformWindow] usableContentFrame];
[window setDelegate:delegate];
[window zoom:self];
[self assertTrue:CGRectEqualToRect([delegate defaultFrame], usableFrame)
message:@"expected " + CPStringFromRect(usableFrame) + " but was " + CPStringFromRect([delegate defaultFrame])];
}
- (void)testWindowShouldZoomCanRefuseTheStandardFrame
{
var window = [self resizableWindow],
standardFrame = CGRectMake(20.0, 30.0, 640.0, 480.0),
delegate = [[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame],
userFrame = [window frame];
[delegate setShouldZoom:NO];
[window setDelegate:delegate];
[window zoom:self];
[self assertWindow:window hasFrame:userFrame];
[self assertTrue:CGRectEqualToRect([delegate proposedFrame], standardFrame)];
}
- (void)testZoomKeepsTheStandardFrameWithinTheMaximumSize
{
var window = [self resizableWindow];
[window setMaxSize:CGSizeMake(500.0, 400.0)];
[window setDelegate:[[CPWindowTestZoomDelegate alloc] initWithStandardFrame:CGRectMake(20.0, 30.0, 640.0, 480.0)]];
[window zoom:self];
[self assertWindow:window hasFrame:CGRectMake(20.0, 30.0, 500.0, 400.0)];
}
- (void)testZoomWithoutAUserFrameLeavesTheStandardFrame
{
var window = [self resizableWindow],
standardFrame = [window frame];
[window setDelegate:[[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame]];
[window zoom:self];
[self assertWindow:window hasFrame:standardFrame];
[self assertTrue:[window isZoomed]];
}
- (void)testAWindowSubclassCanSupplyTheStandardFrame
{
var window = [[CPWindowTestZoomingWindow alloc] initWithContentRect:CGRectMake(100.0, 100.0, 400.0, 300.0)
styleMask:CPTitledWindowMask | CPResizableWindowMask];
[window zoom:self];
[self assertWindow:window hasFrame:CPWindowTestZoomingWindowStandardFrame];
}
- (void)testPerformZoomZoomsOnlyAResizableWindow
{
var standardFrame = CGRectMake(20.0, 30.0, 640.0, 480.0),
resizableWindow = [self resizableWindow],
fixedWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(100.0, 100.0, 400.0, 300.0) styleMask:CPTitledWindowMask],
fixedFrame = [fixedWindow frame];
[resizableWindow setDelegate:[[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame]];
[fixedWindow setDelegate:[[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame]];
[resizableWindow performZoom:self];
[fixedWindow performZoom:self];
[self assertWindow:resizableWindow hasFrame:standardFrame];
[self assertWindow:fixedWindow hasFrame:fixedFrame];
}
- (void)testZoomItemsAreEnabledOnlyForAResizableWindow
{
var resizableWindow = [self resizableWindow],
fixedWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(100.0, 100.0, 400.0, 300.0) styleMask:CPTitledWindowMask];
[@selector(performZoom:), @selector(zoom:)].forEach(function(anAction)
{
var item = [[CPMenuItem alloc] initWithTitle:@"Zoom" action:anAction keyEquivalent:@""];
[self assertTrue:[resizableWindow validateUserInterfaceItem:item] message:CPStringFromSelector(anAction) + " in a resizable window"];
[self assertFalse:[fixedWindow validateUserInterfaceItem:item] message:CPStringFromSelector(anAction) + " in a window that cannot resize"];
});
}
/*
The title bar height comes from the default theme. This theme fixes it, so that the
test does not depend on the theme that another test leaves as the default.
*/
- (CPTheme)themeWithTitleBarHeight:(float)aHeight
{
var theme = [[CPTheme alloc] initWithName:@"CPWindowTestTitleBar"],
template = [[_CPStandardWindowView alloc] initWithFrame:CGRectMake(0.0, 0.0, 400.0, 300.0) styleMask:CPTitledWindowMask];
[template setValue:aHeight forThemeAttribute:@"title-bar-height"];
[theme takeThemeFromObject:template];
return theme;
}
- (void)testDoubleClickInTheTitleBarZoomsTheWindow
{
var defaultTheme = [CPTheme defaultTheme];
[CPTheme setDefaultTheme:[self themeWithTitleBarHeight:32.0]];
try
{
var window = [self resizableWindow],
standardFrame = CGRectMake(20.0, 30.0, 640.0, 480.0),
event = [CPEvent mouseEventWithType:CPLeftMouseDown
location:CGPointMake(200.0, 16.0)
modifierFlags:0
timestamp:0
windowNumber:[window windowNumber]
context:nil
eventNumber:0
clickCount:2
pressure:0];
[window setDelegate:[[CPWindowTestZoomDelegate alloc] initWithStandardFrame:standardFrame]];
[[window _windowView] mouseDown:event];
[self assertWindow:window hasFrame:standardFrame];
}
finally
{
[CPTheme setDefaultTheme:defaultTheme];
}
}
@end
/*
Supplies a fixed standard frame and records the frames the window passes to it.
*/
@implementation CPWindowTestZoomDelegate : CPObject
{
CGRect _standardFrame;
CGRect _defaultFrame @accessors(readonly, property=defaultFrame);
CGRect _proposedFrame @accessors(readonly, property=proposedFrame);
BOOL _shouldZoom @accessors(property=shouldZoom);
}
- (id)initWithStandardFrame:(CGRect)aFrame
{
self = [super init];
if (self)
{
_standardFrame = CGRectMakeCopy(aFrame);
_shouldZoom = YES;
}
return self;
}
- (CGRect)windowWillUseStandardFrame:(CPWindow)aWindow defaultFrame:(CGRect)aFrame
{
_defaultFrame = CGRectMakeCopy(aFrame);
return CGRectMakeCopy(_standardFrame);
}
- (BOOL)windowShouldZoom:(CPWindow)aWindow toFrame:(CGRect)aFrame
{
_proposedFrame = CGRectMakeCopy(aFrame);
return _shouldZoom;
}
@end
var CPWindowTestZoomingWindowStandardFrame = CGRectMake(40.0, 50.0, 600.0, 450.0);
/*
Supplies its own standard frame, as a window subclass may in Cocoa.
*/
@implementation CPWindowTestZoomingWindow : CPWindow
- (CGRect)windowWillUseStandardFrame:(CPWindow)aWindow defaultFrame:(CGRect)aFrame
{
return CGRectMakeCopy(CPWindowTestZoomingWindowStandardFrame);
}
@end
+6
View File
@@ -79,6 +79,12 @@ function DropInWindowButton(aStyleMask, aName)
[self assertFalse:[DropInWindowButton(CPTitledWindowMask | CPClosableWindowMask | CPMiniaturizableWindowMask, @"_minimizeButton") isEnabled]];
}
- (void)testZoomButtonIsEnabledOnlyForAResizableWindow
{
[self assertTrue:[DropInWindowButton(CPTitledWindowMask | CPResizableWindowMask, @"_zoomButton") isEnabled]];
[self assertFalse:[DropInWindowButton(CPTitledWindowMask | CPClosableWindowMask, @"_zoomButton") isEnabled]];
}
- (void)testPopUpButtonKeepsTheDefaultButtonBezelStyle
{
var frame = CGRectMake(0.0, 0.0, 100.0, 24.0),