Files
cappuccino/AppKit/CPWindow/_CPStandardWindowView.j
T
David Richardson c062669fb5 AppKit: Replace OS behaviour API with theme-driven window frame layout
Eliminate brittle platform-specific branching and the incomplete host-platform OS behaviour API, replacing them with a unified layout model where window frames, titles, and controls derive entirely from the active theme, with platform variants selected through a private function.

* CPApplication.j:
  - Remove setOSBehavior:, OSBehavior, shouldMimicWindows, and simulatesWindows accessors, along with associated Info.plist keys and user defaults.
  - Set default theme selection through _CPThemeVariant.

* CPApplication_Constants.j: Remove OS behaviour constants, dictionary, and notification.

* CPCompatibility.j: Add _CPBrowserOperatingSystem to return the detected host operating system.

* CPTheme.j: Remove CPThemeStateWindowsPlatform; add _CPThemeVariant to return the base theme.

* CPWindow.j: Remove the _close delegation to the window view.

* _CPStandardWindowView.j:
  - Remove OS behaviour observer, _close, and _osBehaviorDidChange:.
  - Position buttons and title using theme values during layout, applying fallback defaults when theme values are absent.
  - Read title bar and sheet values from the view's active theme rather than the default theme.

* _CPTitleableWindowView.j: Query title-margin dynamically during layout instead of caching.
2026-11-08 13:19:54 -06:00

592 lines
20 KiB
Plaintext

/*
* _CPStandardWindowView.j
* AppKit
*
* Created by Francisco Tolmasky.
* Copyright 2008, 280 North, Inc.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import "CPButton.j"
@import "_CPTitleableWindowView.j"
@class _CPDocModalWindowView
var _CPStandardWindowViewDividerViewHeight = 1.0;
@implementation _CPTexturedWindowHeadView : CPView
{
BOOL _isSheet @accessors(setter=setSheet:);
_CPWindowView _parentView;
CPView _gradientView;
CPView _solidView;
}
+ (CPString)defaultThemeClass
{
return @"textured-window-head-view";
}
+ (CPDictionary)themeAttributes
{
return @{
@"close-active-image-button": [CPNull null],
@"minimize-active-image-button": [CPNull null],
@"zoom-active-image-button": [CPNull null],
};
}
- (id)initWithFrame:(CGRect)aFrame windowView:(_CPWindowView)parentView
{
self = [super initWithFrame:aFrame];
if (self)
{
_parentView = parentView;
_gradientView = [[CPView alloc] initWithFrame:CGRectMakeZero()];
[self addSubview:_gradientView];
_solidView = [[CPView alloc] initWithFrame:CGRectMakeZero()];
[self addSubview:_solidView];
}
return self;
}
/*
The head view draws with the theme values of its window view, in the window view's
current state, so it follows the window's theme. It read them from the default
theme, so under any other theme it drew the default theme's height and colours.
*/
- (void)layoutSubviews
{
[super layoutSubviews];
var gradientHeight = [_parentView valueForThemeAttribute:@"gradient-height"],
bounds = [self bounds],
bezelHeadColor = [_parentView currentValueForThemeAttribute:_isSheet ? @"bezel-head-sheet-color" : @"bezel-head-color"];
// Apply the border/background color to self (the container) so borders are drawn on the outside
[self setBackgroundColor:bezelHeadColor];
// Set x=0.0 to avoid 1px gap. Width is reduced by 2.0 to fit inside the 1px borders on each side.
[_gradientView setFrame:CGRectMake(0.0, 0.0, CGRectGetWidth(bounds) - 2.0, gradientHeight)];
// Set x=0.0 to avoid 1px gap. Width is reduced by 2.0.
[_solidView setFrame:CGRectMake(0.0, gradientHeight, CGRectGetWidth(bounds) - 2.0, CGRectGetHeight(bounds) - gradientHeight)];
[_solidView setBackgroundColor:[_parentView currentValueForThemeAttribute:@"solid-color"]];
}
- (void)resizeSubviewsWithOldSize:(CGSize)aSize
{
var bounds = [self bounds],
width = CGRectGetWidth(bounds) - 2.0, // Account for border inset
gradientHeight = [_parentView valueForThemeAttribute:@"gradient-height"];
if (width < 0) width = 0;
[_gradientView setFrameSize:CGSizeMake(width, gradientHeight)];
[_solidView setFrameSize:CGSizeMake(width, CGRectGetHeight(bounds) - gradientHeight)];
}
@end
@implementation _CPStandardWindowView : _CPTitleableWindowView
{
_CPTexturedWindowHeadView _headView;
CPView _dividerView;
CPView _bodyView;
CPButton _closeButton;
CPButton _minimizeButton;
CPButton _zoomButton;
BOOL _isDocumentEdited;
BOOL _isSheet;
CPTrackingArea _closeButtonTrackingArea;
CPTrackingArea _minimizeButtonTrackingArea;
CPTrackingArea _zoomButtonTrackingArea;
int _buttonsWidth;
}
+ (CPString)defaultThemeClass
{
return @"standard-window-view";
}
+ (CPDictionary)themeAttributes
{
return @{
@"gradient-height": [CPNull null],
@"solid-color": [CPNull null],
@"bezel-head-color": [CPNull null],
@"bezel-head-sheet-color": [CPNull null],
@"divider-color": [CPColor blackColor],
@"body-color": [CPColor whiteColor],
@"title-bar-height": 32,
@"minimize-image-highlighted-button": [CPNull null],
@"minimize-image-button": [CPNull null],
@"close-image-button": [CPNull null],
@"close-image-highlighted-button": [CPNull null],
@"unsaved-image-button": [CPNull null],
@"unsaved-image-highlighted-button": [CPNull null],
@"zoom-image-button": [CPNull null],
@"zoom-image-highlighted-button": [CPNull null]
};
}
+ (CGRect)contentRectForFrameRect:(CGRect)aFrameRect
{
/*
This window view class draws a frame.
So we have to inset the content rect to be inside the frame.
The top coordinate has already been adjusted by _CPTitleableWindowView.
*/
var contentRect = [super contentRectForFrameRect:aFrameRect];
contentRect.origin.x += 1;
contentRect.size.width -= 2;
contentRect.size.height -= 1;
// Adjust for the divider
contentRect.origin.y += _CPStandardWindowViewDividerViewHeight;
contentRect.size.height -= _CPStandardWindowViewDividerViewHeight;
return contentRect;
}
- (id)initWithFrame:(CGRect)aFrame styleMask:(unsigned)aStyleMask
{
self = [super initWithFrame:aFrame styleMask:aStyleMask];
if (self)
{
var theClass = [self class],
bounds = [self bounds];
_headView = [[_CPTexturedWindowHeadView alloc] initWithFrame:CGRectMake(0.0, 0.0, CGRectGetWidth(bounds), _titleBarHeight) windowView:self];
[_headView setAutoresizingMask:CPViewWidthSizable];
[_headView setHitTests:NO];
[self addSubview:_headView positioned:CPWindowBelow relativeTo:_titleField];
_dividerView = [[CPView alloc] initWithFrame:CGRectMake(0.0, CGRectGetMaxY([_headView frame]), CGRectGetWidth(bounds), _CPStandardWindowViewDividerViewHeight)];
[_dividerView setAutoresizingMask:CPViewWidthSizable];
[_dividerView setHitTests:NO];
[self addSubview:_dividerView];
var y = CGRectGetMinY([_dividerView frame]);
_bodyView = [[CPView alloc] initWithFrame:CGRectMake(0.0, y, CGRectGetWidth(bounds), CGRectGetHeight(bounds) - y)];
[_bodyView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable];
[_bodyView setHitTests:NO];
[self addSubview:_bodyView];
[self setResizeIndicatorOffset:CGSizeMake(2.0, 2.0)];
if (_styleMask & CPClosableWindowMask)
{
_closeButton = [[CPButton alloc] initWithFrame:CGRectMakeZero()];
[_closeButton setButtonType:CPMomentaryChangeButton];
[_closeButton setBordered:NO];
[self addSubview:_closeButton];
}
/*
In the browser a window has only a close button. Outside the browser a
miniaturizable window also has a minimize button. A minimize button in the
browser and a zoom button for every resizable window were added under every
theme, but neither works in the browser: the platform window's -miniaturize:
does nothing, so the window stays on screen but counts as minimized, and no
class implements -performZoom:. Under a theme without zoom images, the zoom
button was also invisible but clickable.
The code below that places, highlights and hides _zoomButton remains, and does
nothing while it is nil. It belongs to the window buttons that are to follow
Apple's guidance: all three standard buttons shown, the unavailable ones
disabled, with the Windows button order as a platform variant. That work
replaces it and the minimize button below.
*/
if (_styleMask & CPMiniaturizableWindowMask && ![CPPlatform isBrowser])
{
_minimizeButton = [[CPButton alloc] initWithFrame:CGRectMakeZero()];
[_minimizeButton setButtonType:CPMomentaryChangeButton];
[_minimizeButton setBordered:NO];
[self addSubview:_minimizeButton];
}
[self _updateWindowButtons];
[self updateTrackingAreas];
[self tile];
}
return self;
}
- (void)viewDidMoveToWindow
{
[_closeButton setTarget:[self window]];
[_closeButton setAction:@selector(performClose:)];
[_minimizeButton setTarget:[self window]];
[_minimizeButton setAction:@selector(performMiniaturize:)];
}
- (CGSize)toolbarOffset
{
return CGSizeMake(0.0, _titleBarHeight);
}
- (void)tile
{
[super tile];
var theWindow = [self window],
bounds = [self bounds],
width = CGRectGetWidth(bounds),
headHeight = [self toolbarMaxY];
if (_isSheet && _toolbarView && [self showsToolbar])
{
headHeight = [_toolbarView frameSize].height;
[_toolbarView setFrameOrigin:CGPointMake(0.0, 0.0)];
}
[_headView setFrameSize:CGSizeMake(width, headHeight)];
// Set divider to full width to prevent gaps/holes at the sides.
[_dividerView setFrame:CGRectMake(0.0, headHeight, width, _CPStandardWindowViewDividerViewHeight)];
var dividerMinY = 0,
dividerFrame = [_dividerView frame];
if (![_dividerView isHidden])
dividerMinY = CGRectGetMinY(dividerFrame);
[_bodyView setFrame:CGRectMake(0.0, dividerMinY, width, CGRectGetHeight(bounds) - dividerMinY)];
[self _layoutTitleField];
var contentFrame = [_bodyView frame];
[[theWindow contentView] setFrame:CGRectInset(contentFrame, 1.0, 1.0)];
}
/*
The buttons are placed from the theme values on every layout, so a change of theme
moves them. They were placed only when the view was made, so the window kept the
old theme's positions. The positions come from the theme alone, with no branch for
the host platform. A theme without positions gets fixed defaults.
*/
- (void)_updateWindowButtons
{
var closeOrigin = CGPointMakeCopy([self currentValueForThemeAttribute:@"close-image-origin"] || CGPointMake(8.0, 8.0)),
minimizeOrigin = CGPointMakeCopy([self currentValueForThemeAttribute:@"minimize-image-origin"] || CGPointMake(27.0, 7.0)),
zoomOrigin = CGPointMakeCopy([self currentValueForThemeAttribute:@"zoom-image-origin"] || CGPointMake(46.0, 8.0)),
delta1 = minimizeOrigin.x - closeOrigin.x,
delta2 = zoomOrigin.x - minimizeOrigin.x;
_buttonsWidth = 0;
if (_styleMask & CPClosableWindowMask)
{
var closeSize = [self currentValueForThemeAttribute:@"close-image-size"] || CGSizeMake(16.0, 16.0);
[_closeButton setFrame:CGRectMake(closeOrigin.x, closeOrigin.y, closeSize.width, closeSize.height)];
_buttonsWidth = closeOrigin.x + closeSize.width;
}
else
{
minimizeOrigin.x -= delta1;
zoomOrigin.x -= delta1;
}
if (_styleMask & CPMiniaturizableWindowMask)
{
var minimizeSize = [self currentValueForThemeAttribute:@"minimize-image-size"] || CGSizeMake(16.0, 16.0);
[_minimizeButton setFrame:CGRectMake(minimizeOrigin.x, minimizeOrigin.y, minimizeSize.width, minimizeSize.height)];
_buttonsWidth = minimizeOrigin.x + minimizeSize.width;
}
else
zoomOrigin.x -= delta2;
if (_styleMask & CPResizableWindowMask)
{
var zoomSize = [self currentValueForThemeAttribute:@"zoom-image-size"] || CGSizeMake(16.0, 16.0);
[_zoomButton setFrame:CGRectMake(zoomOrigin.x, zoomOrigin.y, zoomSize.width, zoomSize.height)];
_buttonsWidth = zoomOrigin.x + zoomSize.width;
}
if (_buttonsWidth > 0)
_buttonsWidth += closeOrigin.x;
[self _updateCloseButton];
[_minimizeButton setImage:[self currentValueForThemeAttribute:@"minimize-image-button"]];
[_minimizeButton setAlternateImage:[self currentValueForThemeAttribute:@"minimize-image-highlighted-button"]];
[_zoomButton setImage:[self currentValueForThemeAttribute:@"zoom-image-button"]];
[_zoomButton setAlternateImage:[self currentValueForThemeAttribute:@"zoom-image-highlighted-button"]];
}
/*
The title fills the space between the buttons and the same width on the other side.
When that is too narrow for the title, the title gives up the space on the other
side first. The title margin is read from the theme here, so a change of theme
moves the title.
*/
- (void)_layoutTitleField
{
var width = CGRectGetWidth([self bounds]),
titleMargin = [self currentValueForThemeAttribute:@"title-margin"],
titleWidth = width - 2.0 * _buttonsWidth;
if (titleWidth < _minimumTitleFieldSize)
{
if (width - _buttonsWidth - titleMargin < _minimumTitleFieldSize)
titleWidth = width - _buttonsWidth - titleMargin;
else
titleWidth = _minimumTitleFieldSize;
}
[_titleField setFrame:CGRectMake(_buttonsWidth, 0, titleWidth, _titleBarHeight)];
}
- (void)_updateCloseButton
{
if (_isDocumentEdited)
{
[_closeButton setImage:[self currentValueForThemeAttribute:@"unsaved-image-button"]];
[_closeButton setAlternateImage:[self valueForThemeAttribute:@"unsaved-image-highlighted-button"]];
}
else
{
[_closeButton setImage:[self currentValueForThemeAttribute:@"close-image-button"]];
[_closeButton setAlternateImage:[self currentValueForThemeAttribute:@"close-image-highlighted-button"]];
}
}
- (void)setDocumentEdited:(BOOL)isEdited
{
_isDocumentEdited = isEdited;
[self _updateCloseButton];
}
- (BOOL)couldBeMoveEvent:(CPEvent)anEvent
{
if (![_headView isHidden])
if (CGRectContainsPoint([_headView frame], [self convertPoint:[anEvent locationInWindow] fromView:nil]))
return YES;
return [super couldBeMoveEvent:anEvent];
}
- (void)_enableSheet:(BOOL)enable inWindow:(CPWindow)parentWindow
{
[super _enableSheet:enable inWindow:parentWindow];
_isSheet = enable;
[_headView setSheet:enable];
if (_toolbarView && [self showsToolbar])
{
[_headView setHidden:NO];
[_dividerView setHidden:NO];
}
else
{
[_headView setHidden:enable];
[_dividerView setHidden:enable];
}
[_closeButton setHidden:enable];
[_minimizeButton setHidden:enable];
[_zoomButton setHidden:enable];
[_titleField setHidden:enable];
[[self window] setMovable:!enable];
if (enable)
{
[_bodyView setBackgroundColor:[[self actualTheme] valueForAttributeWithName:@"body-color" forClass:_CPDocModalWindowView]];
// Move the shadow view down so it is inside the content border
var shadowFrame = [_sheetShadowView frame];
[_sheetShadowView setFrameOrigin:CGPointMake(shadowFrame.origin.x, shadowFrame.origin.y + 1)];
}
else
[_bodyView setBackgroundColor:[self valueForThemeAttribute:@"body-color"]];
// resize the window
var theWindow = [self window],
frame = [theWindow frame],
dividerHeight = [_dividerView frame].size.height,
dy = [self toolbarMaxY] + dividerHeight;
if (_toolbarView && [self showsToolbar])
dy = [self valueForThemeAttribute:@"gradient-height"];
if (enable)
dy = -dy;
var newHeight = CGRectGetHeight(frame) + dy,
newWidth = CGRectGetWidth(frame);
frame.size.height += dy;
[self setFrameSize:CGSizeMake(newWidth, newHeight)];
[self tile];
[theWindow setFrame:frame display:NO animate:NO];
[self setNeedsLayout];
}
- (void)layoutSubviews
{
[super layoutSubviews];
[self _updateWindowButtons];
[self _layoutTitleField];
[_dividerView setBackgroundColor:[self valueForThemeAttribute:@"divider-color"]];
[_bodyView setBackgroundColor:[self valueForThemeAttribute:@"body-color"]];
[_headView setNeedsLayout];
}
- (CGSize)_minimumResizeSize
{
// The minimum width is such that the close/minimize/zoom button(s) would always be visible.
// We give the same margin to the right of the button(s) as there is to the left.
var size = CGSizeMakeCopy([super _minimumResizeSize]);
size.width = _buttonsWidth;
size.height += _CPStandardWindowViewDividerViewHeight;
return size;
}
- (int)bodyOffset
{
return [_bodyView frame].origin.y;
}
// pragma mark -
// pragma mark Hover management for buttons
- (void)updateTrackingAreas
{
if (_closeButtonTrackingArea)
[_closeButton removeTrackingArea:_closeButtonTrackingArea];
if (_minimizeButtonTrackingArea)
[_minimizeButton removeTrackingArea:_minimizeButtonTrackingArea];
if (_zoomButtonTrackingArea)
[_zoomButton removeTrackingArea:_zoomButtonTrackingArea];
if (_closeButton)
{
_closeButtonTrackingArea = [[CPTrackingArea alloc] initWithRect:CGRectMakeZero()
options:CPTrackingMouseEnteredAndExited | CPTrackingActiveAlways | CPTrackingInVisibleRect
owner:self
userInfo:_closeButton];
[_closeButton addTrackingArea:_closeButtonTrackingArea];
}
if (_minimizeButton)
{
_minimizeButtonTrackingArea = [[CPTrackingArea alloc] initWithRect:CGRectMakeZero()
options:CPTrackingMouseEnteredAndExited | CPTrackingActiveAlways | CPTrackingInVisibleRect
owner:self
userInfo:_minimizeButton];
[_minimizeButton addTrackingArea:_minimizeButtonTrackingArea];
}
if (_zoomButton)
{
_zoomButtonTrackingArea = [[CPTrackingArea alloc] initWithRect:CGRectMakeZero()
options:CPTrackingMouseEnteredAndExited | CPTrackingActiveAlways | CPTrackingInVisibleRect
owner:self
userInfo:_zoomButton];
[_zoomButton addTrackingArea:_zoomButtonTrackingArea];
}
[super updateTrackingAreas];
}
- (void)mouseEntered:(CPEvent)anEvent
{
var triggeredButton = [[anEvent trackingArea] userInfo],
state = CPThemeStateHovered;
if (triggeredButton === _closeButton)
{
if (_isDocumentEdited)
[_closeButton setImage:[self valueForThemeAttribute:@"unsaved-image-button" inState:state]];
else
[_closeButton setImage:[self valueForThemeAttribute:@"close-image-button" inState:state]];
}
else if (triggeredButton === _minimizeButton)
{
[_minimizeButton setImage:[self valueForThemeAttribute:@"minimize-image-button" inState:state]];
}
else if (triggeredButton === _zoomButton)
{
[_zoomButton setImage:[self valueForThemeAttribute:@"zoom-image-button" inState:state]];
}
}
- (void)mouseExited:(CPEvent)anEvent
{
var triggeredButton = [[anEvent trackingArea] userInfo];
if (triggeredButton === _closeButton)
{
if (_isDocumentEdited)
[_closeButton setImage:[self currentValueForThemeAttribute:@"unsaved-image-button"]];
else
[_closeButton setImage:[self currentValueForThemeAttribute:@"close-image-button"]];
}
else if (triggeredButton === _minimizeButton)
{
[_minimizeButton setImage:[self currentValueForThemeAttribute:@"minimize-image-button"]];
}
else if (triggeredButton === _zoomButton)
{
[_zoomButton setImage:[self currentValueForThemeAttribute:@"zoom-image-button"]];
}
}
@end