diff --git a/AppKit/CPLevelIndicator.j b/AppKit/CPLevelIndicator.j index 94319181f..ae3ca291f 100644 --- a/AppKit/CPLevelIndicator.j +++ b/AppKit/CPLevelIndicator.j @@ -22,15 +22,10 @@ @import "CPControl.j" @import "CPWindow_Constants.j" +@import "CPSlider.j" @global CPApp -@typedef CPTickMarkPosition -CPTickMarkBelow = 0; -CPTickMarkAbove = 1; -CPTickMarkLeft = CPTickMarkAbove; -CPTickMarkRight = CPTickMarkBelow; - @typedef CPLevelIndicatorStyle CPRelevancyLevelIndicatorStyle = 0; CPContinuousCapacityLevelIndicatorStyle = 1; diff --git a/AppKit/CPSlider.j b/AppKit/CPSlider.j index 2190118d2..b13d4d078 100644 --- a/AppKit/CPSlider.j +++ b/AppKit/CPSlider.j @@ -5,6 +5,9 @@ * Created by Francisco Tolmasky. * Copyright 2009, 280 North, Inc. * + * Adapted by Didier Korthoudt + * Copyright 2018 + * * 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 @@ -26,8 +29,26 @@ /*! SLIDER STATES */ -CPLinearSlider = 0; -CPCircularSlider = 1; +@typedef CPSliderType +CPLinearSlider = 0; +CPCircularSlider = 1; + +/*! SLIDER TICK MARK POSITION */ + +@typedef CPTickMarkPosition +CPTickMarkPositionBelow = 0; +CPTickMarkPositionAbove = 1; +CPTickMarkPositionLeading = CPTickMarkPositionAbove; +CPTickMarkPositionTrailing = CPTickMarkPositionBelow; + +/*! Ticked sliders specific states */ + +CPThemeStateTickedSlider = CPThemeState("ticked-slider"); +CPThemeStateAboveLeftTickedSlider = CPThemeState("above-left-ticked-slider"); +CPThemeStateBelowRightTickedSlider = CPThemeState("below-right-ticked-slider"); + +/*! Tick mark affinity distance */ +var AFFINITY = 5; /*! @ingroup appkit @@ -35,13 +56,30 @@ CPCircularSlider = 1; @implementation CPSlider : CPControl { - double _minValue; - double _maxValue; - double _altIncrementValue; + double _minValue; + double _maxValue; + double _altIncrementValue; - BOOL _isVertical; + CPInteger _isVertical; + BOOL _isCircular; + BOOL _isTicked; + BOOL _isContinuous; - CGSize _dragOffset; + CGSize _dragOffset; + + CPSliderType _sliderType @accessors(property=sliderType); + BOOL _allowsTickMarkValuesOnly @accessors(property=allowsTickMarkValuesOnly); + CPTickMarkPosition _tickMarkPosition @accessors(property=tickMarkPosition); + CPInteger _numberOfTickMarks @accessors(property=numberOfTickMarks); + + CPMutableArray _cachedTickMarksRects; + CPMutableArray _cachedTickMarksSegments; + CPMutableArray _cachedTickMarksValues; + CPInteger _currentTickMarkSegment; + CPInteger _closestTickMarkIndex; + CPInteger _affinityPoint; + BOOL _canStickOnTickMarks; + BOOL _stickingOnTickMark; } + (CPString)defaultThemeClass @@ -52,10 +90,19 @@ CPCircularSlider = 1; + (CPDictionary)themeAttributes { return @{ - @"knob-color": [CPNull null], - @"knob-size": CGSizeMakeZero(), - @"track-width": 0.0, - @"track-color": [CPNull null], + @"left-track-color": [CPNull null], + @"knob-color": [CPNull null], + @"knob-size": CGSizeMakeZero(), + @"knob-offset": 0.0, + @"track-width": 0.0, + @"track-color": [CPNull null], + @"direct-nib2cib-adjustment": NO, + @"tick-mark-size": CGSizeMakeZero(), + @"tick-mark-color": [CPNull null], + @"tick-mark-margin": 0, + @"top-margin": 0, + @"bottom-margin": 0, + @"ib-size": -1 }; } @@ -69,10 +116,11 @@ CPCircularSlider = 1; _maxValue = 100.0; [self setObjectValue:50.0]; - [self setContinuous:YES]; + [self setNumberOfTickMarks:0]; + [self setSliderType:CPLinearSlider]; - [self _recalculateIsVertical]; + [self _refreshCachesAndStates]; } return self; @@ -90,6 +138,8 @@ CPCircularSlider = 1; if (doubleValue < _minValue) [self setDoubleValue:_minValue]; + [self _refreshCachesAndStates]; + // The relative position may have (did) change. [self setNeedsLayout]; [self setNeedsDisplay:YES]; @@ -112,6 +162,8 @@ CPCircularSlider = 1; if (doubleValue > _maxValue) [self setDoubleValue:_maxValue]; + [self _refreshCachesAndStates]; + // The relative position may have (did) change. [self setNeedsLayout]; [self setNeedsDisplay:YES]; @@ -124,7 +176,9 @@ CPCircularSlider = 1; - (void)setObjectValue:(id)aValue { + [self willChangeValueForKey:@"objectValue"]; [super setObjectValue:MIN(MAX(aValue, _minValue), _maxValue)]; + [self didChangeValueForKey:@"objectValue"]; [self setNeedsLayout]; [self setNeedsDisplay:YES]; @@ -132,51 +186,115 @@ CPCircularSlider = 1; - (void)setSliderType:(CPSliderType)aSliderType { - if (aSliderType === CPCircularSlider) - [self setThemeState:CPThemeStateCircular]; - else - [self unsetThemeState:CPThemeStateCircular]; + if (aSliderType === _sliderType) + return; + + _sliderType = aSliderType; + + [self _refreshCachesAndStates]; } - (CPSliderType)sliderType { - return [self hasThemeState:CPThemeStateCircular] ? CPCircularSlider : CPLinearSlider; + return _sliderType; } - (CGRect)trackRectForBounds:(CGRect)bounds { - if ([self hasThemeState:CPThemeStateCircular]) + var trackRect = CGRectCreateCopy(bounds); + + if (_isCircular) { - var originalBounds = CGRectCreateCopy(bounds); + var tickMarkMargin = [self currentValueForThemeAttribute:@"tick-mark-margin"]; - bounds.size.width = MIN(bounds.size.width, bounds.size.height); - bounds.size.height = bounds.size.width; + trackRect.size.width = MIN(trackRect.size.width, trackRect.size.height) - tickMarkMargin * 2; + trackRect.size.height = trackRect.size.width; - if (bounds.size.width < originalBounds.size.width) - bounds.origin.x += (originalBounds.size.width - bounds.size.width) / 2.0; - else - bounds.origin.y += (originalBounds.size.height - bounds.size.height) / 2.0; + trackRect.origin.x = tickMarkMargin; + trackRect.origin.y = tickMarkMargin; } else { - var trackWidth = [self currentValueForThemeAttribute:@"track-width"]; + var trackWidth = [self currentValueForThemeAttribute:@"track-width"], + tickMarkSize = [self currentValueForThemeAttribute:@"tick-mark-size"], + tickMarkMargin = [self currentValueForThemeAttribute:@"tick-mark-margin"], + topMargin = [self currentValueForThemeAttribute:@"top-margin"], + bottomMargin = [self currentValueForThemeAttribute:@"bottom-margin"]; if (trackWidth <= 0) return CGRectMakeZero(); - if ([self isVertical]) + if (_isVertical) { - bounds.origin.x = (CGRectGetWidth(bounds) - trackWidth) / 2.0; - bounds.size.width = trackWidth; + var usableWidth = trackRect.size.width - topMargin - bottomMargin - (_isTicked ? (tickMarkSize.width + tickMarkMargin) : 0); + + trackRect.origin.x = (usableWidth - trackWidth) / 2.0 + topMargin + ((_isTicked && (_tickMarkPosition === CPTickMarkPositionLeading)) ? (tickMarkSize.width + tickMarkMargin) : 0); + trackRect.size.width = trackWidth; } else { - bounds.origin.y = (CGRectGetHeight(bounds) - trackWidth) / 2.0; - bounds.size.height = trackWidth; + var usableHeight = trackRect.size.height - topMargin - bottomMargin - (_isTicked ? (tickMarkSize.height + tickMarkMargin) : 0); + + trackRect.origin.y = (usableHeight - trackWidth) / 2.0 + topMargin + ((_isTicked && (_tickMarkPosition === CPTickMarkPositionAbove)) ? (tickMarkSize.height + tickMarkMargin) : 0); + trackRect.size.height = trackWidth; } } - return bounds; + return trackRect; +} + +- (CGRect)leftTrackRectForBounds:(CGRect)bounds +{ + // Circular and ticked slider do not have left and right parts, so we don't need to deal with them + if (_isCircular || _isTicked) + return CGRectMakeZero(); + + var trackRect = CGRectCreateCopy(bounds), + trackWidth = [self currentValueForThemeAttribute:@"track-width"], + currentValue = [self doubleValue]; + + if (_isVertical) + { + trackRect.origin.x = (trackRect.size.width - trackWidth) / 2.0; + trackRect.origin.y = ((_maxValue - currentValue) / (_maxValue - _minValue)) * trackRect.size.height; + trackRect.size.width = trackWidth; + trackRect.size.height = ((currentValue - _minValue) / (_maxValue - _minValue)) * trackRect.size.height; + } + else + { + trackRect.origin.y = (trackRect.size.height - trackWidth) / 2.0; + trackRect.size.width = ((currentValue - _minValue) / (_maxValue - _minValue)) * trackRect.size.width; + trackRect.size.height = trackWidth; + } + + return trackRect; +} + +- (CGRect)rightTrackRectForBounds:(CGRect)bounds +{ + // Circular and ticked slider do not have left and right parts, so we don't need to deal with them + if (_isCircular || _isTicked) + return CGRectMakeZero(); + + var trackRect = CGRectCreateCopy(bounds), + trackWidth = [self currentValueForThemeAttribute:@"track-width"], + currentValue = [self doubleValue]; + + if (_isVertical) + { + trackRect.origin.x = (trackRect.size.width - trackWidth) / 2.0; + trackRect.size.width = trackWidth; + trackRect.size.height = ((_maxValue - currentValue) / (_maxValue - _minValue)) * trackRect.size.height; + } + else + { + trackRect.origin.x = ((currentValue - _minValue) / (_maxValue - _minValue)) * trackRect.size.width; + trackRect.origin.y = (trackRect.size.height - trackWidth) / 2.0; + trackRect.size.width = ((_maxValue - currentValue) / (_maxValue - _minValue)) * trackRect.size.width; + trackRect.size.height = trackWidth; + } + + return trackRect; } - (CGRect)knobRectForBounds:(CGRect)bounds @@ -186,30 +304,41 @@ CPCircularSlider = 1; if (knobSize.width <= 0 || knobSize.height <= 0) return CGRectMakeZero(); - var knobRect = CGRectMake(0.0, 0.0, knobSize.width, knobSize.height), - trackRect = [self trackRectForBounds:bounds]; + var knobRect = CGRectMake(0.0, 0.0, knobSize.width, knobSize.height), + trackRect = [self trackRectForBounds:bounds], + knobOffset = [self currentValueForThemeAttribute:@"knob-offset"]; // No track, do our best to approximate a place for this thing. if (!trackRect || CGRectIsEmpty(trackRect)) trackRect = bounds; - if ([self hasThemeState:CPThemeStateCircular]) + if (_isCircular) { - var angle = 3 * PI_2 - (1.0 - [self doubleValue] - _minValue) / (_maxValue - _minValue) * PI2, - radius = CGRectGetWidth(trackRect) / 2.0 - 8.0; - - knobRect.origin.x = radius * COS(angle) + CGRectGetMidX(trackRect) - 3.0; - knobRect.origin.y = radius * SIN(angle) + CGRectGetMidY(trackRect) - 2.0; + var angle = 3 * PI_2 - (1.0 - [self doubleValue] - _minValue) / (_maxValue - _minValue) * PI2, + radius = trackRect.size.width / 2.0 - (knobOffset ? knobOffset : 8.0); + + knobRect.origin.x = radius * COS(angle) + CGRectGetMidX(trackRect) - knobSize.width / 2; // 3.0; + knobRect.origin.y = radius * SIN(angle) + CGRectGetMidY(trackRect) - knobSize.height / 2; // 2.0; } - else if ([self isVertical]) + else if (_isVertical) { - knobRect.origin.x = CGRectGetMidX(trackRect) - knobSize.width / 2.0; - knobRect.origin.y = ((_maxValue - [self doubleValue]) / (_maxValue - _minValue)) * (CGRectGetHeight(trackRect) - knobSize.height); + knobRect.origin.x = CGRectGetMidX(trackRect) - knobSize.width / 2.0 + knobOffset; + + if (_isTicked && _allowsTickMarkValuesOnly && _cachedTickMarksRects) + // This is done to garantee that the knob is perfectly aligned with the tick mark + knobRect.origin.y = _cachedTickMarksRects[_currentTickMarkSegment].origin.y - (knobSize.height - 1) / 2; + else + knobRect.origin.y = ROUND(((_maxValue - [self doubleValue]) / (_maxValue - _minValue)) * (trackRect.size.height - knobSize.height)); } else { - knobRect.origin.x = (([self doubleValue] - _minValue) / (_maxValue - _minValue)) * (CGRectGetWidth(trackRect) - knobSize.width); - knobRect.origin.y = CGRectGetMidY(trackRect) - knobSize.height / 2.0; + if (_isTicked && _allowsTickMarkValuesOnly && _cachedTickMarksRects) + // This is done to garantee that the knob is perfectly aligned with the tick mark + knobRect.origin.x = _cachedTickMarksRects[_currentTickMarkSegment].origin.x - (knobSize.width - 1) / 2; + else + knobRect.origin.x = ROUND((([self doubleValue] - _minValue) / (_maxValue - _minValue)) * (trackRect.size.width - knobSize.width)); + + knobRect.origin.y = CGRectGetMidY(trackRect) - knobSize.height / 2.0 + knobOffset; } return knobRect; @@ -217,18 +346,28 @@ CPCircularSlider = 1; - (CGRect)rectForEphemeralSubviewNamed:(CPString)aName { - if (aName === "track-view") - return [self trackRectForBounds:[self bounds]]; + switch (aName) + { + case @"track-view": + return [self trackRectForBounds:[self bounds]]; - else if (aName === "knob-view") - return [self knobRectForBounds:[self bounds]]; + case @"knob-view": + return [self knobRectForBounds:[self bounds]]; - return [super rectForEphemeralSubviewNamed:aName]; + case @"left-track-view": + return [self leftTrackRectForBounds:[self bounds]]; + + case @"right-track-view": + return [self rightTrackRectForBounds:[self bounds]]; + + default: + return [super rectForEphemeralSubviewNamed:aName]; + } } - (CPView)createEphemeralSubviewNamed:(CPString)aName { - if (aName === "track-view" || aName === "knob-view") + if (aName === "track-view" || aName === "knob-view" || aName === "left-track-view" || aName === "right-track-view") { var view = [[CPView alloc] init]; @@ -242,6 +381,7 @@ CPCircularSlider = 1; - (void)setAltIncrementValue:(float)anAltIncrementValue { + // FIXME: This is not used. BTW, tested in Cocoa and it is totally useless. So leave it like that for now... _altIncrementValue = anAltIncrementValue; } @@ -250,47 +390,64 @@ CPCircularSlider = 1; return _altIncrementValue; } -- (void)setFrameSize:(CGSize)aSize -{ - [super setFrameSize:aSize]; - [self _recalculateIsVertical]; -} - -- (void)_recalculateIsVertical -{ - // Recalculate isVertical. - var bounds = [self bounds], - width = CGRectGetWidth(bounds), - height = CGRectGetHeight(bounds); - - _isVertical = width < height ? 1 : (width > height ? 0 : -1); - - if (_isVertical === 1) - [self setThemeState:CPThemeStateVertical]; - else if (_isVertical === 0) - [self unsetThemeState:CPThemeStateVertical]; -} - -- (int)isVertical +- (CPInteger)isVertical { return _isVertical; } - (void)layoutSubviews { - var trackView = [self layoutEphemeralSubviewNamed:@"track-view" - positioned:CPWindowBelow - relativeToEphemeralSubviewNamed:@"knob-view"]; + var leftTrackColor = [self currentValueForThemeAttribute:@"left-track-color"]; - if (trackView) + if (leftTrackColor && !_isCircular && !_isTicked) + { + // Two parts layout (needed by Aristo3) + var leftTrackView = [self layoutEphemeralSubviewNamed:@"left-track-view" + positioned:CPWindowBelow + relativeToEphemeralSubviewNamed:@"knob-view"], + rightTrackView = [self layoutEphemeralSubviewNamed:@"right-track-view" + positioned:CPWindowBelow + relativeToEphemeralSubviewNamed:@"knob-view"]; + + [leftTrackView setBackgroundColor:leftTrackColor]; + [rightTrackView setBackgroundColor:[self currentValueForThemeAttribute:@"track-color"]]; + + var knobView = [self layoutEphemeralSubviewNamed:@"knob-view" + positioned:CPWindowAbove + relativeToEphemeralSubviewNamed:@"left-track-view"]; + + [knobView setBackgroundColor:[self currentValueForThemeAttribute:"knob-color"]]; + } + else + { + // Normal layout + var trackView = [self layoutEphemeralSubviewNamed:@"track-view" + positioned:CPWindowBelow + relativeToEphemeralSubviewNamed:@"knob-view"]; + [trackView setBackgroundColor:[self currentValueForThemeAttribute:@"track-color"]]; - var knobView = [self layoutEphemeralSubviewNamed:@"knob-view" - positioned:CPWindowAbove - relativeToEphemeralSubviewNamed:@"track-view"]; - - if (knobView) + var knobView = [self layoutEphemeralSubviewNamed:@"knob-view" + positioned:CPWindowAbove + relativeToEphemeralSubviewNamed:@"track-view"]; + [knobView setBackgroundColor:[self currentValueForThemeAttribute:"knob-color"]]; + } +} + +- (void)drawRect:(CGRect)aRect +{ + // We only draw tick marks here + + if (!_isTicked) + return; + + var context = [[CPGraphicsContext currentContext] graphicsPort]; + + CGContextSetFillColor(context, [self currentValueForThemeAttribute:@"tick-mark-color"]); + + for (var i = 0; i < _numberOfTickMarks; i++) + CGContextFillRect(context, [self rectOfTickMarkAtIndex:i]); } - (BOOL)tracksMouseOutsideOfFrame @@ -298,71 +455,28 @@ CPCircularSlider = 1; return YES; } -- (float)_valueAtPoint:(CGPoint)aPoint -{ - var bounds = [self bounds], - knobRect = [self knobRectForBounds:bounds], - trackRect = [self trackRectForBounds:bounds]; - - if ([self hasThemeState:CPThemeStateCircular]) - { - var knobWidth = CGRectGetWidth(knobRect); - - trackRect.origin.x += knobWidth / 2; - trackRect.size.width -= knobWidth; - - var minValue = [self minValue], - dx = aPoint.x - CGRectGetMidX(trackRect), - dy = aPoint.y - CGRectGetMidY(trackRect); - - return MAX(0.0, MIN(1.0, 1.0 - (3 * PI_2 - ATAN2(dy, dx)) % PI2 / PI2)) * ([self maxValue] - minValue) + minValue; - } - else if ([self isVertical]) - { - var knobHeight = CGRectGetHeight(knobRect); - - trackRect.origin.y += knobHeight / 2; - trackRect.size.height -= knobHeight; - - var minValue = [self minValue]; - - return MAX(0.0, MIN(1.0, (CGRectGetMaxY(trackRect) - aPoint.y) / CGRectGetHeight(trackRect))) * ([self maxValue] - minValue) + minValue; - } - else - { - var knobWidth = CGRectGetWidth(knobRect); - - trackRect.origin.x += knobWidth / 2; - trackRect.size.width -= knobWidth; - - var minValue = [self minValue]; - - return MAX(0.0, MIN(1.0, (aPoint.x - CGRectGetMinX(trackRect)) / CGRectGetWidth(trackRect))) * ([self maxValue] - minValue) + minValue; - } -} - - (BOOL)startTrackingAt:(CGPoint)aPoint { - var bounds = [self bounds], - knobRect = [self knobRectForBounds:CGRectMakeCopy(bounds)]; + var bounds = [self bounds], + knobRect = [self knobRectForBounds:bounds]; if (CGRectContainsPoint(knobRect, aPoint)) _dragOffset = CGSizeMake(CGRectGetMidX(knobRect) - aPoint.x, CGRectGetMidY(knobRect) - aPoint.y); else { - var trackRect = [self trackRectForBounds:bounds]; + _dragOffset = CGSizeMakeZero(); - if (trackRect && CGRectContainsPoint(trackRect, aPoint)) + if (_allowsTickMarkValuesOnly) { - _dragOffset = CGSizeMakeZero(); - - [self setObjectValue:[self _valueAtPoint:aPoint]]; + _currentTickMarkSegment = [self indexOfTickMarkAtPoint:aPoint]; + [self setObjectValue:_cachedTickMarksValues[_currentTickMarkSegment]]; } - else - return NO; + [self setObjectValue:[self _valueAtPoint:aPoint]]; } + _stickingOnTickMark = NO; + [self setHighlighted:YES]; [self setNeedsLayout]; @@ -373,7 +487,57 @@ CPCircularSlider = 1; - (BOOL)continueTracking:(CGPoint)lastPoint at:(CGPoint)aPoint { - [self setObjectValue:[self _valueAtPoint:CGPointMake(aPoint.x + _dragOffset.width, aPoint.y + _dragOffset.height)]]; + var bounds = [self bounds], + newPoint = _isCircular ? CGPointMake(aPoint.x + _dragOffset.width, aPoint.y + _dragOffset.height) + : CGPointMake(MAX(0, MIN(bounds.size.width-1, aPoint.x + _dragOffset.width)), + MAX(0, MIN(bounds.size.height-1, aPoint.y + _dragOffset.height))); + + if (_allowsTickMarkValuesOnly) + { + var tickMarkSegment = [self indexOfTickMarkAtPoint:newPoint]; + + if (tickMarkSegment !== _currentTickMarkSegment) + { + _currentTickMarkSegment = tickMarkSegment; + [self setObjectValue:_cachedTickMarksValues[_currentTickMarkSegment]]; + } + } + + else if (_canStickOnTickMarks) + { + // Ticked but not constrained to tick marks + // We're using a tick affinity with a latency of AFFINITY pixels + + // Search the nearest tick mark + var tickMarkSegment = [self indexOfTickMarkAtPoint:newPoint], + tickRect = _cachedTickMarksRects[tickMarkSegment]; + + // Does the knob point to it ? + if ((!_isVertical && (newPoint.x === CGRectGetMidX(tickRect))) || + ( _isVertical && (newPoint.y === CGRectGetMidY(tickRect)))) + { + // Start an affinity + [self setObjectValue:_cachedTickMarksValues[tickMarkSegment]]; + + _stickingOnTickMark = YES; + _affinityPoint = _isVertical ? newPoint.y : newPoint.x; + } + + // Are we going too far from the tick mark ? + else if (_stickingOnTickMark && (ABS((_isVertical ? newPoint.y : newPoint.x) - _affinityPoint) > AFFINITY)) + { + // Yes. Stop affinity + + _stickingOnTickMark = NO; + [self setObjectValue:[self _valueAtPoint:newPoint]]; + } + + else if (!_stickingOnTickMark) // Normal situation + [self setObjectValue:[self _valueAtPoint:newPoint]]; + } + + else // Not ticked at all or no possible affinity + [self setObjectValue:[self _valueAtPoint:newPoint]]; return YES; } @@ -391,16 +555,14 @@ CPCircularSlider = 1; - (BOOL)isContinuous { - return (_sendActionOn & CPLeftMouseDraggedMask) !== 0; + return _isContinuous; } -/*! - @ignore - should we have _continuous? -*/ - (void)setContinuous:(BOOL)flag { - if (flag) + _isContinuous = flag; + + if (_isContinuous) _sendActionOn |= CPLeftMouseDraggedMask; else _sendActionOn &= ~CPLeftMouseDraggedMask; @@ -418,11 +580,491 @@ CPCircularSlider = 1; return [self setFloatValue:1.0]; } +#pragma mark - New methods as in High Sierra (10.13) + +/*! + Creates and returns a continuous horizontal slider whose values range from 0.0 to 1.0. + @param aTarget + @param anAction + */ ++ (CPSlider)sliderWithTarget:(id)aTarget action:(SEL)anAction +{ + return [self sliderWithValue:0.0 minValue:0.0 maxValue:1.0 target:aTarget action:anAction]; +} + +// Creates and returns a continuous horizontal slider that represents values over the specified range. ++ (CPSlider)sliderWithValue:(double)value minValue:(double)minValue maxValue:(double)maxValue target:(id)target action:(SEL)action +{ + var slider = [[self alloc] initWithFrame:CGRectMake(0, 0, 104, 19)]; // Size used by Cocoa + + [slider setMinValue:minValue]; + [slider setMaxValue:maxValue]; + [slider setDoubleValue:value]; + [slider setTarget:target]; + [slider setAction:action]; + [slider setContinuous:YES]; + + [slider _refreshCachesAndStates]; + + return slider; +} + +// The knob’s thickness, in pixels. +// The thickness is defined to be the extent of the knob along the long dimension of the bar. +// In a vertical slider, a knob’s thickness is its height; in a horizontal slider, a knob’s thickness is its width. +- (float)knobThickness +{ + var knobSize = [self currentValueForThemeAttribute:@"knob-size"]; + + return _isVertical ? knobSize.height : knobSize.width; +} + +// allowsTickMarkValuesOnly : A Boolean value that indicates whether the receiver fixes +// its values to those values represented by its tick marks. + +// YES if the slider fixes its values to the values represented by its tick marks; otherwise, NO. +// For example, if a slider has a minimum value of 0, a maximum value of 100, and five markers, +// the allowable values are 0, 25, 50, 75, and 100. When users move the slider’s knob, +// it jumps to the tick mark nearest the cursor when the mouse button is released. +// This method has no effect if the slider has no tick marks. + +- (void)setAllowsTickMarkValuesOnly:(BOOL)shouldAllowTickMarkValuesOnly +{ + if (shouldAllowTickMarkValuesOnly === _allowsTickMarkValuesOnly) + return; + + [self willChangeValueForKey:@"allowsTickMarkValuesOnly"]; + _allowsTickMarkValuesOnly = shouldAllowTickMarkValuesOnly; + [self didChangeValueForKey:@"allowsTickMarkValuesOnly"]; + + [self _refreshCachesAndStates]; + [self setNeedsDisplay:YES]; +} + +// Returns the value of the tick mark closest to the specified value. +- (double)closestTickMarkValueToValue:(double)value +{ + if (_numberOfTickMarks === 1) + return _cachedTickMarksValues[0]; + + _closestTickMarkIndex = CPNotFound; + + var foundDelta = 2 * _maxValue, + delta; + + for (var i = 0; i < _numberOfTickMarks; i++) + { + delta = ABS(value - _cachedTickMarksValues[i]); + + if (delta < foundDelta) + { + foundDelta = delta; + _closestTickMarkIndex = i; + } + } + + return _closestTickMarkIndex ? _cachedTickMarksValues[_closestTickMarkIndex] : _minValue; +} + +// Returns the index of the tick mark closest to the location of the receiver represented by the given point. +- (CPInteger)indexOfTickMarkAtPoint:(CGPoint)point +{ + var foundIndex = CPNotFound; + + if (_isCircular) + { + // CPCircularSlider + + var bounds = [self bounds], + dx = point.x - bounds.size.width / 2, + dy = bounds.size.height / 2 - point.y, + angle = 1 + (((-3 * PI_2 - ATAN2(dy, dx)) % PI2) / PI2); + + for (var i = 0; (i < _numberOfTickMarks) && (foundIndex === CPNotFound); i++) + if ((angle < _cachedTickMarksSegments[i]) || ((i === 0) && (angle >= (1 - _cachedTickMarksSegments[i])))) + foundIndex = i; + } + else + { + // CPLinearSlider + + if (_isVertical) + { + for (var i = 0; (i < _numberOfTickMarks) && (foundIndex === CPNotFound); i++) + if (point.y <= _cachedTickMarksSegments[i]) + foundIndex = i; + } + else + { + for (var i = 0; (i < _numberOfTickMarks) && (foundIndex === CPNotFound); i++) + if (point.x <= _cachedTickMarksSegments[i]) + foundIndex = i; + } + } + + return foundIndex; +} + +// The number of the slider's tick marks. The tick marks assigned to the minimum and maximum values are included. +- (void)setNumberOfTickMarks:(CPInteger)aNumber +{ + if (aNumber === _numberOfTickMarks) + return; + + // We try to play nicer than Cocoa when passing from a non-ticked slider to a ticked one (and vice versa) + var tickedChanged = (((_numberOfTickMarks == 0) && (aNumber > 0)) || ((_numberOfTickMarks > 0) && (aNumber == 0))); + + [self willChangeValueForKey:@"numberOfTickMarks"]; + _numberOfTickMarks = aNumber; + [self didChangeValueForKey:@"numberOfTickMarks"]; + + // If tickedChanged, this refresh is necessary to set new states but we'll have to call it again after for correct geometry + [self _refreshCachesAndStates]; + + if (tickedChanged) + { + // First, remove ephemeral subviews as they may change with new state (needed ones will be created by layoutSubviews) + + [self removeEphemeralSubviewNamed:@"track-view"]; + [self removeEphemeralSubviewNamed:@"left-track-view"]; + [self removeEphemeralSubviewNamed:@"right-track-view"]; + [self removeEphemeralSubviewNamed:@"knob-view"]; + + // Now adapt the frame size to match what Xcode IB would have provided + + var currentSize = [self frameSize], + newSize = [self currentValueForThemeAttribute:@"ib-size"]; + + if (_isCircular) + [self setFrameSize:CGSizeMake(newSize, newSize)]; + + else if (_isVertical) + [self setFrameSize:CGSizeMake(newSize, currentSize.height)]; + + else + [self setFrameSize:CGSizeMake(currentSize.width, newSize)]; + + // _refreshCachesAndStates, setNeedsDisplay and setNeedsLayout are called in setFrameSize + } + else + [self setNeedsDisplay:YES]; +} + +// Returns the bounding rectangle of the tick mark at the given index. +- (CGRect)rectOfTickMarkAtIndex:(CPInteger)index +{ + // If no tick mark is associated with index, the method raises NSRangeException + if ((index < 0) || (index >= _numberOfTickMarks)) + [CPException raise:CPRangeException reason:@"rectOfTickMarkAtIndex: index ("+index+") is outside permitted values (0.."+(_numberOfTickMarks-1)+")"]; + + return _cachedTickMarksRects[index]; +} + +// tickMarkPosition : Determines how the receiver’s tick marks are aligned with it. +// Possible values are CPTickMarkBelow, CPTickMarkAbove, CPTickMarkLeft, and CPTickMarkRight +// (the last two are for vertical sliders). The default alignments are CPTickMarkBelow and CPTickMarkLeft. +// This property has no effect if no tick marks have been assigned (that is, numberOfTickMarks returns 0). +- (void)setTickMarkPosition:(CPTickMarkPosition)aTickMarkPosition +{ + if (aTickMarkPosition === _tickMarkPosition) + return; + + [self willChangeValueForKey:@"tickMarkPosition"]; + _tickMarkPosition = aTickMarkPosition; + [self didChangeValueForKey:@"tickMarkPosition"]; + + [self _refreshCachesAndStates]; + [self setNeedsDisplay:YES]; +} + +// Returns the receiver’s value represented by the tick mark at the specified index. +- (double)tickMarkValueAtIndex:(CPInteger)index +{ + // If no tick mark is associated with index, the method raises NSRangeException + if ((index < 0) || (index >= _numberOfTickMarks)) + [CPException raise:CPRangeException reason:@"tickMarkValueAtIndex: index ("+index+") is outside permitted values (0.."+(_numberOfTickMarks-1)+")"]; + + return _cachedTickMarksValues[index]; +} + +// The color of the filled portion of the slider track, in appearances that support it. +- (void)setTrackFillColor:(CPColor)aColor +{ + var normalState = _isVertical ? [CPThemeStateVertical, CPThemeStateKeyWindow] : [CPThemeStateKeyWindow], + leftTrackColor = [self valueForThemeAttribute:@"left-track-color" inStates:normalState]; + + if (leftTrackColor === aColor) + return; + + [self setValue:aColor forThemeAttribute:@"left-track-color" inStates:normalState]; +} + +- (CPColor)trackFillColor +{ + var normalState = _isVertical ? [CPThemeStateVertical, CPThemeStateKeyWindow] : [CPThemeStateKeyWindow]; + + return [self valueForThemeAttribute:@"left-track-color" inStates:normalState]; +} + +#pragma mark - Private methods + +- (void)_refreshCachesAndStates +{ + // Purge caches + _cachedTickMarksRects = @[]; + _cachedTickMarksValues = @[]; + _cachedTickMarksSegments = @[]; + + // Reset affinity + _canStickOnTickMarks = NO; + + // Recalculate _isVertical. + var bounds = [self bounds], + boundsWidth = bounds.size.width, + boundsHeight = bounds.size.height; + + _isVertical = (boundsWidth < boundsHeight) ? 1 : ((boundsWidth > boundsHeight) ? 0 : -1); + + if (_isVertical) + [self setThemeState:CPThemeStateVertical]; + else + [self unsetThemeState:CPThemeStateVertical]; + + // Recalculate _isCircular + _isCircular = (_sliderType === CPCircularSlider); + + if (_isCircular) + [self setThemeState:CPThemeStateCircular]; + else + [self unsetThemeState:CPThemeStateCircular]; + + // Recalculate _isTicked + _isTicked = (_numberOfTickMarks > 0); + + // If this slider has no tick marks, we're done. + if (!_isTicked) + { + [self unsetThemeState:CPThemeStateTickedSlider]; + [self unsetThemeState:CPThemeStateAboveLeftTickedSlider]; + [self unsetThemeState:CPThemeStateBelowRightTickedSlider]; + return; + } + + // Set specific theme states + [self setThemeState:CPThemeStateTickedSlider]; + + if (!_isCircular) + if (_tickMarkPosition === CPTickMarkPositionAbove) + { + [self setThemeState:CPThemeStateAboveLeftTickedSlider]; + [self unsetThemeState:CPThemeStateBelowRightTickedSlider]; + } + else + { + [self unsetThemeState:CPThemeStateAboveLeftTickedSlider]; + [self setThemeState:CPThemeStateBelowRightTickedSlider]; + } + + // Calculate cached values : tick marks rects, values and segments + + var tickSize = [self currentValueForThemeAttribute:@"tick-mark-size"], + tickMarkMargin = [self currentValueForThemeAttribute:@"tick-mark-margin"], + topMargin = [self currentValueForThemeAttribute:@"top-margin"], + bottomMargin = [self currentValueForThemeAttribute:@"bottom-margin"], + knobSize = [self currentValueForThemeAttribute:@"knob-size"], + width = tickSize.width, + height = tickSize.height, + x, + y, + tickRect, + value; + + if (_isCircular) + { + // CPCircularSlider + + // Remark : Cocoa doesn't use tick affinity with circular sliders + + var delta = 1 / _numberOfTickMarks, + dX = width / 2, + dY = height / 2, + cX = boundsWidth / 2, + cY = boundsHeight / 2, + radius = cX - dX, + deltaValue = (_maxValue - _minValue) / _numberOfTickMarks; + + value = _minValue; + + for (var i = 0, angle; i < _numberOfTickMarks; i++, value += deltaValue) + { + angle = PI_2 - PI2 * i * delta; + x = radius * COS(angle) + cX; + y = radius * SIN(-angle) + cY; // Vertical coordinates are reversed in Cappuccino + + tickRect = CGRectMake(ROUND(x - dX), ROUND(y - dY), width, height); + + [_cachedTickMarksRects addObject:tickRect]; + [_cachedTickMarksValues addObject:value]; + [_cachedTickMarksSegments addObject:(i + 0.5) * delta]; + } + } + else + { + // CPLinearSlider + + if (_isVertical) + { + // Vertical slider + + // If there's only one tick mark, it's centered. + // If there's more than one tick mark, there's at least one at the min value and one at the max value + + var usableHeight = boundsHeight - knobSize.height, + dY = (height - 1)/2; + + x = (_tickMarkPosition === CPTickMarkPositionLeading) ? topMargin : (boundsWidth - width - bottomMargin); + + if (_numberOfTickMarks === 1) + { + y = (boundsHeight - height) / 2; + value = (_maxValue - _minValue) / 2 + _minValue; + tickRect = CGRectMake(x, ROUND(y - dY), width, height); + + [_cachedTickMarksRects addObject:tickRect]; + [_cachedTickMarksValues addObject:value]; + [_cachedTickMarksSegments addObject:bounds]; + } + else + { + var delta = usableHeight / (_numberOfTickMarks - 1), + deltaValue = (_maxValue - _minValue) / (_numberOfTickMarks - 1); + + y = (knobSize.height - 1) / 2; + value = _maxValue; + _canStickOnTickMarks = !_allowsTickMarkValuesOnly && (delta > AFFINITY+3); + + for (var i = 0; i < _numberOfTickMarks; i++, y += delta, value -= deltaValue) + { + tickRect = CGRectMake(x, ROUND(y - dY), width, height); + + [_cachedTickMarksRects addObject:tickRect]; + [_cachedTickMarksValues addObject:(i < _numberOfTickMarks - 1) ? value : _minValue]; // Avoid rounding problems on last tick mark + [_cachedTickMarksSegments addObject:(i < _numberOfTickMarks - 1) ? ROUND(y + delta/2) : boundsHeight]; + } + } + } + else + { + // Horizontal slider + + // If there's only one tick mark, it's centered. + // If there's more than one tick mark, there's at least one at the min value and one at the max value + + var usableWidth = boundsWidth - knobSize.width, + dX = (width - 1)/2; + + y = (_tickMarkPosition === CPTickMarkPositionAbove) ? topMargin : (boundsHeight - height - bottomMargin); + + if (_numberOfTickMarks === 1) + { + x = (boundsWidth - width) / 2; + value = (_maxValue - _minValue) / 2 + _minValue; + tickRect = CGRectMake(ROUND(x - dX), y, width, height); + + [_cachedTickMarksRects addObject:tickRect]; + [_cachedTickMarksValues addObject:value]; + [_cachedTickMarksSegments addObject:boundsWidth]; + } + else + { + var delta = usableWidth / (_numberOfTickMarks - 1), + deltaValue = (_maxValue - _minValue) / (_numberOfTickMarks - 1); + + x = (knobSize.width - 1) / 2; + value = _minValue; + _canStickOnTickMarks = !_allowsTickMarkValuesOnly && (delta > AFFINITY+3); + + for (var i = 0; i < _numberOfTickMarks; i++, x += delta, value += deltaValue) + { + tickRect = CGRectMake(ROUND(x - dX), y, width, height); + + [_cachedTickMarksRects addObject:tickRect]; + [_cachedTickMarksValues addObject:(i < _numberOfTickMarks - 1) ? value : _maxValue]; // Avoid rounding problems on last tick mark + [_cachedTickMarksSegments addObject:(i < _numberOfTickMarks - 1) ? ROUND(x + delta/2) : boundsWidth]; + } + } + } + } + +#if PLATFORM(DOM) + // Don't do this in nib2cib !.. + if (_allowsTickMarkValuesOnly) + { + [self setObjectValue:[self closestTickMarkValueToValue:[self doubleValue]]]; + _currentTickMarkSegment = _closestTickMarkIndex; + [self sendAction:[self action] to:[self target]]; + } +#endif +} + +/*! @ignore */ +- (float)_valueAtPoint:(CGPoint)aPoint +{ + var bounds = [self bounds], + knobSize = [self currentValueForThemeAttribute:@"knob-size"], + trackRect = [self trackRectForBounds:bounds], + value; + + if (_isCircular) + { + var dx = aPoint.x - CGRectGetMidX(trackRect), + dy = CGRectGetMidY(trackRect) - aPoint.y; + + value = ((1 + (((-3 * PI_2 - ATAN2(dy, dx)) % PI2) / PI2)) * (_maxValue - _minValue) + _minValue) % _maxValue; + } + else if (_isVertical) + { + var knobHeight = knobSize.height; + + trackRect.origin.y += knobHeight / 2; + trackRect.size.height -= knobHeight; + + value = MAX(0.0, MIN(1.0, (trackRect.origin.y + trackRect.size.height - aPoint.y) / trackRect.size.height)) * (_maxValue - _minValue) + _minValue; + } + else + { + var knobWidth = knobSize.width; + + trackRect.origin.x += knobWidth / 2; + trackRect.size.width -= knobWidth; + + value = MAX(0.0, MIN(1.0, (aPoint.x - trackRect.origin.x) / trackRect.size.width)) * (_maxValue - _minValue) + _minValue; + } + + return value; +} + +#pragma mark - Overrides + +- (void)setFrameSize:(CGSize)aSize +{ + [super setFrameSize:aSize]; + [self _refreshCachesAndStates]; + [self setNeedsLayout]; + [self setNeedsDisplay:YES]; +} + +#pragma mark - + @end -var CPSliderMinValueKey = "CPSliderMinValueKey", - CPSliderMaxValueKey = "CPSliderMaxValueKey", - CPSliderAltIncrValueKey = "CPSliderAltIncrValueKey"; +var CPSliderMinValueKey = "CPSliderMinValueKey", + CPSliderMaxValueKey = "CPSliderMaxValueKey", + CPSliderAltIncrValueKey = "CPSliderAltIncrValueKey", + CPSliderTypeKey = "CPSliderTypeKey", + CPSliderAllowsTickMarkValuesOnlyKey = "CPSliderAllowsTickMarkValuesOnlyKey", + CPSliderTickMarkPositionKey = "CPSliderTickMarkPositionKey", + CPSliderNumberOfTickMarksKey = "CPSliderNumberOfTickMarksKey"; @implementation CPSlider (CPCoding) @@ -435,9 +1077,15 @@ var CPSliderMinValueKey = "CPSliderMinValueKey", if (self) { - _altIncrementValue = [aCoder decodeDoubleForKey:CPSliderAltIncrValueKey]; + _altIncrementValue = [aCoder decodeDoubleForKey:CPSliderAltIncrValueKey]; + _sliderType = [aCoder decodeIntForKey: CPSliderTypeKey]; + _allowsTickMarkValuesOnly = [aCoder decodeBoolForKey: CPSliderAllowsTickMarkValuesOnlyKey]; + _tickMarkPosition = [aCoder decodeIntForKey: CPSliderTickMarkPositionKey]; + _numberOfTickMarks = [aCoder decodeIntForKey: CPSliderNumberOfTickMarksKey]; - [self _recalculateIsVertical]; + _isContinuous = (_sendActionOn & CPLeftMouseDraggedMask) !== 0; + + [self _refreshCachesAndStates]; [self setNeedsLayout]; [self setNeedsDisplay:YES]; @@ -450,9 +1098,13 @@ var CPSliderMinValueKey = "CPSliderMinValueKey", { [super encodeWithCoder:aCoder]; - [aCoder encodeDouble:_minValue forKey:CPSliderMinValueKey]; - [aCoder encodeDouble:_maxValue forKey:CPSliderMaxValueKey]; - [aCoder encodeDouble:_altIncrementValue forKey:CPSliderAltIncrValueKey]; + [aCoder encodeDouble:_minValue forKey:CPSliderMinValueKey]; + [aCoder encodeDouble:_maxValue forKey:CPSliderMaxValueKey]; + [aCoder encodeDouble:_altIncrementValue forKey:CPSliderAltIncrValueKey]; + [aCoder encodeInt: _sliderType forKey:CPSliderTypeKey]; + [aCoder encodeBool: _allowsTickMarkValuesOnly forKey:CPSliderAllowsTickMarkValuesOnlyKey]; + [aCoder encodeInt: _tickMarkPosition forKey:CPSliderTickMarkPositionKey]; + [aCoder encodeInt: _numberOfTickMarks forKey:CPSliderNumberOfTickMarksKey]; } @end diff --git a/Tools/nib2cib/NSSlider.j b/Tools/nib2cib/NSSlider.j index bfbc53ee3..a614af099 100644 --- a/Tools/nib2cib/NSSlider.j +++ b/Tools/nib2cib/NSSlider.j @@ -24,6 +24,7 @@ @import "NSCell.j" +@class Nib2Cib @implementation CPSlider (CPCoding) @@ -44,6 +45,9 @@ _altIncrementValue = [cell altIncrementValue]; [self setSliderType:[cell sliderType]]; + [self setAllowsTickMarkValuesOnly:[cell allowsTickMarkValuesOnly]]; + [self setTickMarkPosition:[cell tickMarkPosition]]; + [self setNumberOfTickMarks:[cell numberOfTickMarks]]; [self setEnabled:[cell isEnabled]]; } @@ -63,12 +67,17 @@ [self NS_initWithCell:cell]; [self _adjustNib2CibSize]; - var frame = [self frame]; + var directAdjustment = [[Nib2Cib defaultTheme] valueForAttributeWithName:@"direct-nib2cib-adjustment" forClass:[self class]]; - if ([self sliderType] === CPCircularSlider) - [self setFrameSize:CGSizeMake(frame.size.width + 2.0, frame.size.height + 2.0)]; - else - [self setFrame:CGRectMake(frame.origin.x + 2, frame.origin.y, frame.size.width - 4, frame.size.height)]; + if (!directAdjustment) + { + var frame = [self frame]; + + if ([self sliderType] === CPCircularSlider) + [self setFrameSize:CGSizeMake(frame.size.width + 2.0, frame.size.height + 2.0)]; + else + [self setFrame:CGRectMake(frame.origin.x + 2, frame.origin.y, frame.size.width - 4, frame.size.height)]; + } } return self; @@ -79,15 +88,62 @@ return [CPSlider class]; } +- (CGRect)_nib2CibAdjustment +{ + var directAdjustment = [[Nib2Cib defaultTheme] valueForAttributeWithName:@"direct-nib2cib-adjustment" forClass:[self class]]; + + if (!directAdjustment) + return [super _nib2CibAdjustment]; + + var size = [self frameSize], + state; + + if ([self sliderType] === CPCircularSlider) + state = CPThemeStateCircular; + else if (size.height > size.width) + { + state = CPThemeStateVertical; + state = state.and(([self tickMarkPosition] === CPTickMarkPositionTrailing) ? CPThemeStateBelowRightTickedSlider : CPThemeStateAboveLeftTickedSlider); + } + else + { + state = CPThemeStateNormal; + state = state.and(([self tickMarkPosition] === CPTickMarkPositionBelow) ? CPThemeStateBelowRightTickedSlider : CPThemeStateAboveLeftTickedSlider); + } + + if ([self numberOfTickMarks] > 0) + state = state.and(CPThemeStateTickedSlider); + + // Theme has not been loaded yet. + // Get attribute value directly from the theme or from the default value of the object otherwise. + var frameAdjustment = [[Nib2Cib defaultTheme] valueForAttributeWithName:@"nib2cib-adjustment-frame" inState:state forClass:[self class]]; + + if (frameAdjustment) + return frameAdjustment; + + if ([self hasThemeAttribute:@"nib2cib-adjustment-frame"]) + { + frameAdjustment = [self valueForThemeAttribute:@"nib2cib-adjustment-frame" inState:state]; + + if (frameAdjustment) + return frameAdjustment; + } + + return nil; +} + @end @implementation NSSliderCell : NSCell { - double _minValue @accessors(readonly, getter=minValue); - double _maxValue @accessors(readonly, getter=maxValue); - double _altIncrementValue @accessors(readonly, getter=altIncrementValue); - BOOL _vertical @accessors(readonly, getter=isVertical); - int _sliderType @accessors(readonly, getter=sliderType); + double _minValue @accessors(readonly, getter=minValue); + double _maxValue @accessors(readonly, getter=maxValue); + double _altIncrementValue @accessors(readonly, getter=altIncrementValue); + BOOL _vertical @accessors(readonly, getter=isVertical); + int _sliderType @accessors(readonly, getter=sliderType); + BOOL _allowsTickMarkValuesOnly @accessors(readonly, getter=allowsTickMarkValuesOnly); + int _tickMarkPosition @accessors(readonly, getter=tickMarkPosition); + int _numberOfTickMarks @accessors(readonly, getter=numberOfTickMarks); } - (id)initWithCoder:(CPCoder)aCoder @@ -103,7 +159,10 @@ self._altIncrementValue = [aCoder decodeDoubleForKey:@"NSAltIncValue"]; self._isVertical = [aCoder decodeBoolForKey:@"NSVertical"]; - self._sliderType = [aCoder decodeIntForKey:@"NSSliderType"]; + self._sliderType = [aCoder decodeIntForKey:@"NSSliderType"]; + self._allowsTickMarkValuesOnly = [aCoder decodeIntForKey:@"NSAllowsTickMarkValuesOnly"]; + self._tickMarkPosition = [aCoder decodeIntForKey:@"NSTickMarkPosition"]; + self._numberOfTickMarks = [aCoder decodeIntForKey:@"NSNumberOfTickMarks"]; } return self;