/* CPRulerView.j Created by Daniel Boehringer on 08/01/2016 Copyright Daniel Boehringer 2016. 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 @import @import @import @import @import @import @import @class CPRulerMarker @global CPHorizontalRuler @global CPVerticalRuler // Constants for layout var DEFAULT_RULE_THICKNESS = 16.0, DEFAULT_MARKER_THICKNESS = 15.0, HASH_MARK_THICKNESS_FACTOR = 0.6, HASH_MARK_WIDTH = 1.0, LABEL_TEXT_PADDING = 2.0; var _measurementUnits = nil; // ----------------------------------------------------------------------------- // Helper Class: _CPMeasurementUnit // ----------------------------------------------------------------------------- @implementation _CPMeasurementUnit : CPObject { CPString _name @accessors(property = name); CPString _abbreviation @accessors(property = abbreviation); float _pointsPerUnit @accessors(property = pointsPerUnit); CPArray _stepUpCycle @accessors(property = stepUpCycle); CPArray _stepDownCycle @accessors(property = stepDownCycle); } + (void)initialize { if (self !== [_CPMeasurementUnit class]) return; _measurementUnits = []; [self registerUnit:[_CPMeasurementUnit inchesMeasurementUnit]]; [self registerUnit:[_CPMeasurementUnit centimetersMeasurementUnit]]; [self registerUnit:[_CPMeasurementUnit pointsMeasurementUnit]]; [self registerUnit:[_CPMeasurementUnit picasMeasurementUnit]]; } + (_CPMeasurementUnit)measurementUnitWithName:(CPString)name abbreviation:(CPString)abbreviation pointsPerUnit:(float)points stepUpCycle:(CPArray)upCycle stepDownCycle:(CPArray)downCycle { return [[self alloc] initWithName:name abbreviation:abbreviation pointsPerUnit:points stepUpCycle:upCycle stepDownCycle:downCycle]; } - (id)initWithName:(CPString)name abbreviation:(CPString)abbreviation pointsPerUnit:(float)points stepUpCycle:(CPArray)upCycle stepDownCycle:(CPArray)downCycle { self = [super init]; if (self) { _name = name; _abbreviation = abbreviation; _pointsPerUnit = points; _stepUpCycle = upCycle; _stepDownCycle = downCycle; } return self; } + (_CPMeasurementUnit)inchesMeasurementUnit { return [self measurementUnitWithName:@"Inches" abbreviation:@"in" pointsPerUnit:72.0 stepUpCycle:[ [CPNumber numberWithFloat:2.0], [CPNumber numberWithFloat:5.0], [CPNumber numberWithFloat:10.0] ] stepDownCycle:[ [CPNumber numberWithFloat:0.5], [CPNumber numberWithFloat:0.25], [CPNumber numberWithFloat:0.125] ]]; } + (_CPMeasurementUnit)centimetersMeasurementUnit { return [self measurementUnitWithName:@"Centimeters" abbreviation:@"cm" pointsPerUnit:28.3465 stepUpCycle:[ [CPNumber numberWithFloat:2.0], [CPNumber numberWithFloat:5.0], [CPNumber numberWithFloat:10.0] ] stepDownCycle:[ [CPNumber numberWithFloat:0.5], [CPNumber numberWithFloat:0.1] ]]; } + (_CPMeasurementUnit)pointsMeasurementUnit { return [self measurementUnitWithName:@"Points" abbreviation:@"pt" pointsPerUnit:1.0 stepUpCycle:[ [CPNumber numberWithFloat:10.0], [CPNumber numberWithFloat:50.0], [CPNumber numberWithFloat:100.0] ] stepDownCycle:[ [CPNumber numberWithFloat:0.5] ]]; // Points rarely sub-divide } + (_CPMeasurementUnit)picasMeasurementUnit { return [self measurementUnitWithName:@"Picas" abbreviation:@"pc" pointsPerUnit:12.0 stepUpCycle:[ [CPNumber numberWithFloat:2.0], [CPNumber numberWithFloat:5.0], [CPNumber numberWithFloat:10.0] ] stepDownCycle:[ [CPNumber numberWithFloat:0.5], [CPNumber numberWithFloat:0.0833] ]]; // 6p and 1pt } + (CPArray)allMeasurementUnits { return _measurementUnits; } + (_CPMeasurementUnit)measurementUnitNamed:(CPString)name { var i = 0, count = [_measurementUnits count]; for (; i < count; ++i) if ([[[_measurementUnits objectAtIndex:i] name] isEqualToString:name]) return [_measurementUnits objectAtIndex:i]; return nil; } + (void)registerUnit:(_CPMeasurementUnit)unit { if (!_measurementUnits) _measurementUnits = []; [_measurementUnits addObject:unit]; } @end // ----------------------------------------------------------------------------- // Class: CPRulerView // ----------------------------------------------------------------------------- @implementation CPRulerView : CPView { CPScrollView _scrollView; CPView _clientView; CPView _accessoryView; CPArray _markers; _CPMeasurementUnit _measurementUnit; CPMutableArray _rulerlineLocations; float _originOffset; float _ruleThickness; float _thicknessForMarkers; float _thicknessForAccessoryView; CPRulerOrientation _orientation; // Cache attributes CPDictionary _labelAttributes; } // MARK: - Class Methods + (void)registerUnitWithName:(CPString)name abbreviation:(CPString)abbreviation unitToPointsConversionFactor:(float)conversionFactor stepUpCycle:(CPArray)stepUpCycle stepDownCycle:(CPArray)stepDownCycle { [_CPMeasurementUnit registerUnit:[_CPMeasurementUnit measurementUnitWithName:name abbreviation:abbreviation pointsPerUnit:conversionFactor stepUpCycle:stepUpCycle stepDownCycle:stepDownCycle]]; } // MARK: - Initialization - (id)initWithFrame:(CPRect)frame { return [self initWithScrollView:nil orientation:CPHorizontalRuler]; } - (id)initWithScrollView:(CPScrollView)scrollView orientation:(CPRulerOrientation)orientation { var frame = CPMakeRect(0, 0, 1, 1); // Determine initial frame based on standard thickness if (orientation == CPHorizontalRuler) frame.size.height = DEFAULT_RULE_THICKNESS; else frame.size.width = DEFAULT_RULE_THICKNESS; self = [super initWithFrame:frame]; if (self) { _scrollView = scrollView; _orientation = orientation; _measurementUnit = [_CPMeasurementUnit measurementUnitNamed:@"Inches"]; _ruleThickness = DEFAULT_RULE_THICKNESS; _thicknessForMarkers = 0.0; // Grows as needed _thicknessForAccessoryView = 0.0; _originOffset = 0.0; _markers = []; _rulerlineLocations = []; var style = [[CPParagraphStyle defaultParagraphStyle] mutableCopy]; [style setLineBreakMode:CPLineBreakByClipping]; [style setAlignment:CPLeftTextAlignment]; _labelAttributes = @{ CPFontAttributeName: [CPFont systemFontOfSize:9.0], CPParagraphStyleAttributeName: style, CPForegroundColorAttributeName: [CPColor blackColor] }; } return self; } - (id)initWithCoder:(CPCoder)aCoder { self = [super initWithCoder:aCoder]; if (self) { _scrollView = [aCoder decodeObjectForKey:@"CPScrollView"]; _orientation = [aCoder decodeIntForKey:@"CPOrientation"]; _markers = [aCoder decodeObjectForKey:@"CPMarkers"]; if (!_markers) _markers = []; // Defaults _ruleThickness = DEFAULT_RULE_THICKNESS; _measurementUnit = [_CPMeasurementUnit measurementUnitNamed:@"Inches"]; _rulerlineLocations = []; } return self; } // MARK: - Layout & Metrics - (float)reservedThicknessForMarkers { if ([_markers count] > 0 && _thicknessForMarkers < DEFAULT_MARKER_THICKNESS) return DEFAULT_MARKER_THICKNESS; return _thicknessForMarkers; } - (float)reservedThicknessForAccessoryView { return _thicknessForAccessoryView; } - (float)ruleThickness { return _ruleThickness; } - (float)requiredThickness { var result = [self ruleThickness]; if ([_markers count] > 0) result += [self reservedThicknessForMarkers]; if (_accessoryView) result += [self reservedThicknessForAccessoryView]; return result; } - (float)baselineLocation { // The baseline is the line separating the ruler from the content. // In a horizontal ruler, it's the bottom edge (height). // Usually, the hash marks grow upwards from this baseline. return [self bounds].size.height; } // MARK: - Accessors - (CPScrollView)scrollView { return _scrollView; } - (void)setScrollView:(CPScrollView)scrollView { _scrollView = scrollView; [self setNeedsDisplay:YES]; } - (CPRulerOrientation)orientation { return _orientation; } - (void)setOrientation:(CPRulerOrientation)orientation { _orientation = orientation; [self setNeedsDisplay:YES]; } - (CPView)clientView { return _clientView; } - (void)setClientView:(CPView)view { if (_clientView === view) return; if ([_clientView respondsToSelector:@selector(rulerView:willSetClientView:)]) [_clientView rulerView:self willSetClientView:view]; // Standard behavior: clear markers when client changes unless preserved [_markers removeAllObjects]; _clientView = view; [self invalidateHashMarks]; [[self enclosingScrollView] tile]; } - (CPView)accessoryView { return _accessoryView; } - (void)setAccessoryView:(CPView)view { if (_accessoryView === view) return; [_accessoryView removeFromSuperview]; _accessoryView = view; if (_accessoryView) { [self addSubview:_accessoryView]; // Usually you'd set the frame here based on thickness } [[self enclosingScrollView] tile]; } - (CPArray)markers { return _markers; } - (void)setMarkers:(CPArray)markers { if (_markers === markers) return; _markers = [markers mutableCopy]; [[self enclosingScrollView] tile]; [self setNeedsDisplay:YES]; } - (void)addMarker:(CPRulerMarker)marker { [_markers addObject:marker]; [marker setRulerView:self]; // Ensure marker knows its ruler [[self enclosingScrollView] tile]; [self setNeedsDisplay:YES]; } - (void)removeMarker:(CPRulerMarker)marker { [_markers removeObject:marker]; [[self enclosingScrollView] tile]; [self setNeedsDisplay:YES]; } - (void)setMeasurementUnits:(CPString)unitName { var unit = [_CPMeasurementUnit measurementUnitNamed:unitName]; if (unit) { _measurementUnit = unit; [self setNeedsDisplay:YES]; } } - (CPString)measurementUnits { return [_measurementUnit name]; } - (void)setOriginOffset:(float)value { _originOffset = value; [self setNeedsDisplay:YES]; } - (float)originOffset { return _originOffset; } - (void)setRuleThickness:(float)value { _ruleThickness = value; [[self enclosingScrollView] tile]; } - (void)setReservedThicknessForMarkers:(float)value { _thicknessForMarkers = value; [[self enclosingScrollView] tile]; } - (void)setReservedThicknessForAccessoryView:(float)value { _thicknessForAccessoryView = value; [[self enclosingScrollView] tile]; } // MARK: - Event Handling - (BOOL)trackMarker:(CPRulerMarker)marker withMouseEvent:(CPEvent)event { // Convert event location to ruler coordinates var point = [self convertPoint:[event locationInWindow] fromView:nil]; // Basic hit testing logic if (CPPointInRect(point, [self bounds])) { // Marker implements trackMouse:adding: if ([marker respondsToSelector:@selector(trackMouse:adding:)]) { [marker trackMouse:event adding:YES]; } [self setNeedsDisplay:YES]; return YES; } return NO; } - (void)mouseDown:(CPEvent)event { var point = [self convertPoint:[event locationInWindow] fromView:nil], i = 0, count = [_markers count]; // 1. Check if an existing marker was clicked for (; i < count; ++i) { var marker = [_markers objectAtIndex:i]; if (CPPointInRect(point, [marker imageRectInRuler])) { [marker trackMouse:event adding:NO]; [self setNeedsDisplay:YES]; return; } } // 2. Delegate to Client View (e.g. to create a new guide/marker) if ([_clientView respondsToSelector:@selector(rulerView:handleMouseDown:)]) [_clientView rulerView:self handleMouseDown:event]; } // MARK: - Drawing Support - (void)moveRulerlineFromLocation:(float)fromLocation toLocation:(float)toLocation { var oldLoc = [CPNumber numberWithFloat:fromLocation], newLoc = [CPNumber numberWithFloat:toLocation]; [_rulerlineLocations removeObject:oldLoc]; // Only add if it's not effectively "off" (using -1 as a convention for hiding) if (toLocation >= 0) [_rulerlineLocations addObject:newLoc]; [self setNeedsDisplay:YES]; } - (void)invalidateHashMarks { [self setNeedsDisplay:YES]; } // MARK: - Drawing Implementation - (BOOL)isFlipped { if (_orientation == CPHorizontalRuler) return YES; // Horizontal rulers usually draw top-down or match standard flipped views // For vertical rulers, we usually want to match the document view's flip state // so numbers increase going down if the document does. if (_clientView) return [_clientView isFlipped]; return YES; } - (float)_drawingScale { // Zoom support var scale = 1.0, docView = [_scrollView documentView]; if (docView && [docView superview]) { // Calculate scale based on bounds vs frame var bounds = [docView bounds], frame = [docView frame]; if (_orientation == CPHorizontalRuler) scale = frame.size.width / bounds.size.width; else scale = frame.size.height / bounds.size.height; } return scale; } - (float)_drawingOrigin { // Calculate the point in the ruler that corresponds to "0" in the client view var origin = 0.0, trackedView = _clientView; if (!trackedView) trackedView = [_scrollView documentView]; if (!trackedView) return 0.0; // Convert (0,0) of the tracked view to the ruler's coordinate space var viewZero = [self convertPoint:CGPointMake(0,0) fromView:trackedView]; if (_orientation == CPHorizontalRuler) origin = viewZero.x; else origin = viewZero.y; // Apply user-defined offset (e.g. if the user dragged the zero-point) // Note: originOffset is in client coordinates, so we scale it. origin += (_originOffset * [self _drawingScale]); return origin; } - (void)drawHashMarksAndLabelsInRect:(CPRect)dirtyRect { var bounds = [self bounds], scale = [self _drawingScale], zeroLocation = [self _drawingOrigin], // x position (horiz) or y position (vert) where 0 is pointsPerUnit = [_measurementUnit pointsPerUnit] * scale; // Avoid divide by zero if (pointsPerUnit <= 0) return; var isHorizontal = (_orientation == CPHorizontalRuler), ruleSize = isHorizontal ? bounds.size.height : bounds.size.width; // Adjust for markers/accessory thickness area var reserved = 0.0; if ([_markers count] > 0) reserved += [self reservedThicknessForMarkers]; if (_accessoryView) reserved += [self reservedThicknessForAccessoryView]; // The actual area for hashes var hashAreaSize = ruleSize - reserved, hashBaseline = reserved; // Drawing starts after reserved area // Calculate range of units to draw based on dirtyRect var startPos = isHorizontal ? dirtyRect.origin.x : dirtyRect.origin.y, endPos = isHorizontal ? CPReectGetMaxX(dirtyRect) : CPReectGetMaxY(dirtyRect); // Convert view coordinates to Unit coordinates // pos = zeroLocation + (unit * pointsPerUnit) // unit = (pos - zeroLocation) / pointsPerUnit var startUnit = Math.floor((startPos - zeroLocation) / pointsPerUnit), endUnit = Math.ceil((endPos - zeroLocation) / pointsPerUnit); var stepDowns = [_measurementUnit stepDownCycle], numSteps = [stepDowns count]; [[CPColor grayColor] setStroke]; // Iterate through units for (var u = startUnit; u <= endUnit; u++) { var unitPos = zeroLocation + (u * pointsPerUnit); // 1. Draw Major Mark var majorHeight = hashAreaSize * HASH_MARK_THICKNESS_FACTOR; [self _drawHashAt:unitPos length:majorHeight offset:hashBaseline horizontal:isHorizontal]; // 2. Draw Label (only for major units) var labelStr = [CPString stringWithFormat:@"%d", u]; // Simple label positioning var labelPoint; if (isHorizontal) labelPoint = CPMakePoint(unitPos + LABEL_TEXT_PADDING, hashBaseline); else labelPoint = CPMakePoint(hashBaseline + LABEL_TEXT_PADDING, unitPos); [labelStr drawAtPoint:labelPoint withAttributes:_labelAttributes]; // 3. Draw Subdivisions // We handle a simple single-level subdivision for performance, // or iterate the cycle. Let's do a simple iterative cycle. // E.g. stepDownCycle: [0.5, 0.25, 0.125] implies 1/2, then 1/4, etc. // But the CPMeasurementUnit spec in the prompt suggests relative steps. // Let's assume the array contains fractions of the unit: e.g. [0.5, 0.1] for (var s = 0; s < numSteps; s++) { var stepFraction = [[stepDowns objectAtIndex:s] floatValue]; if (stepFraction <= 0) continue; // Determine visual spacing. If marks are too close, stop recursing. var pixelsPerStep = pointsPerUnit * stepFraction; if (pixelsPerStep < 4.0) break; // Calculate height: gradually smaller var subHeight = majorHeight * (0.7 / (s + 1)); // How many marks fit in one unit? 1 / fraction. // e.g. 0.5 -> 2 marks (at 0.0 and 0.5). 0.0 is major, so we draw at 0.5. var subCount = Math.round(1.0 / stepFraction); for (var k = 1; k < subCount; k++) { // We only draw if this position wasn't covered by a higher-level step. // Simplified: Just draw everything, simpler logic for UI. // Optimization: Skip if integer check passes? No, keep it simple. var subUnitOffset = k * stepFraction; // Only draw if this isn't a whole integer (covered by major) if (subUnitOffset % 1.0 === 0) continue; var subPos = unitPos + (subUnitOffset * pointsPerUnit); // Bounds check optimization if (subPos < startPos || subPos > endPos) continue; [self _drawHashAt:subPos length:subHeight offset:hashBaseline horizontal:isHorizontal]; } } } } - (void)_drawHashAt:(float)pos length:(float)length offset:(float)offset horizontal:(BOOL)isHorizontal { var path = [CPBezierPath bezierPath]; [path setLineWidth:1.0]; if (isHorizontal) { // Draw vertical line at 'pos' [path moveToPoint:CPMakePoint(pos + 0.5, offset + [self bounds].size.height - length)]; // Draw from bottom up [path lineToPoint:CPMakePoint(pos + 0.5, [self bounds].size.height)]; // Or if flipped (top-down), draw from offset down // Since we force isFlipped=YES for horizontal, y=0 is top. // Typically rulers align marks to the edge touching the content. [path moveToPoint:CPMakePoint(pos + 0.5, offset + [self bounds].size.height - length)]; [path lineToPoint:CPMakePoint(pos + 0.5, offset + [self bounds].size.height)]; } else { // Draw horizontal line at 'pos' [path moveToPoint:CPMakePoint(offset + [self bounds].size.width - length, pos + 0.5)]; [path lineToPoint:CPMakePoint(offset + [self bounds].size.width, pos + 0.5)]; } [path stroke]; } - (void)drawMarkersInRect:(CPRect)dirtyRect { var count = [_markers count]; for (var i = 0; i < count; i++) { var m = [_markers objectAtIndex:i]; if (CPRectIntersectsRect([m imageRectInRuler], dirtyRect)) [m drawRect:dirtyRect]; } } - (void)drawRulerlineLocationsInRect:(CPRect)rect { var count = [_rulerlineLocations count]; if (count === 0) return; [[CPColor controlShadowColor] setStroke]; var bounds = [self bounds]; for (var i = 0; i < count; ++i) { var loc = [[_rulerlineLocations objectAtIndex:i] floatValue]; var path = [CPBezierPath bezierPath]; if (_orientation == CPHorizontalRuler) { [path moveToPoint:CPMakePoint(loc + 0.5, 0)]; [path lineToPoint:CPMakePoint(loc + 0.5, bounds.size.height)]; } else { [path moveToPoint:CPMakePoint(0, loc + 0.5)]; [path lineToPoint:CPMakePoint(bounds.size.width, loc + 0.5)]; } [path stroke]; } } - (void)drawRect:(CPRect)dirtyRect { var context = [[CPGraphicsContext currentContext] graphicsPort]; // 1. Draw Background [[CPColor colorWithCalibratedWhite:0.96 alpha:1.0] setFill]; CGContextFillRect(context, dirtyRect); // 2. Draw Bottom/Right Border (Baseline) [[CPColor darkGrayColor] setStroke]; var borderPath = [CPBezierPath bezierPath]; var bounds = [self bounds]; if (_orientation == CPHorizontalRuler) { [borderPath moveToPoint:CPMakePoint(0, bounds.size.height - 0.5)]; [borderPath lineToPoint:CPMakePoint(bounds.size.width, bounds.size.height - 0.5)]; } else { [borderPath moveToPoint:CPMakePoint(bounds.size.width - 0.5, 0)]; [borderPath lineToPoint:CPMakePoint(bounds.size.width - 0.5, bounds.size.height)]; } [borderPath stroke]; // 3. Draw Contents [self drawHashMarksAndLabelsInRect:dirtyRect]; if ([_markers count] > 0) [self drawMarkersInRect:dirtyRect]; if ([_rulerlineLocations count] > 0) [self drawRulerlineLocationsInRect:dirtyRect]; } @end @implementation CPRulerMarker : CPObject { CPRulerView _ruler; float _markerLocation; CPImage _image; CPPoint _imageOrigin; BOOL _movable @accessors(property=movable); BOOL _removable @accessors(property=removable); id _representedObject @accessors(property=representedObject); // Internal state BOOL _dragging @accessors(getter=isDragging); } // MARK: - Initialization - (id)initWithRulerView:(CPRulerView)aRuler markerLocation:(float)location image:(CPImage)anImage imageOrigin:(CPPoint)anImageOrigin { self = [super init]; if (self) { _ruler = aRuler; _markerLocation = location; _image = anImage; _imageOrigin = anImageOrigin; _movable = NO; _removable = NO; _dragging = NO; } return self; } - (id)initWithCoder:(CPCoder)aCoder { self = [super init]; if (self) { // Ruler is usually assigned after decoding by the view hierarchy _markerLocation = [aCoder decodeFloatForKey:@"CPMarkerLocation"]; _image = [aCoder decodeObjectForKey:@"CPImage"]; _imageOrigin = [aCoder decodePointForKey:@"CPImageOrigin"]; _movable = [aCoder decodeBoolForKey:@"CPMovable"]; _removable = [aCoder decodeBoolForKey:@"CPRemovable"]; _representedObject = [aCoder decodeObjectForKey:@"CPRepresentedObject"]; } return self; } - (void)encodeWithCoder:(CPCoder)aCoder { [aCoder encodeFloat:_markerLocation forKey:@"CPMarkerLocation"]; [aCoder encodeObject:_image forKey:@"CPImage"]; [aCoder encodePoint:_imageOrigin forKey:@"CPImageOrigin"]; [aCoder encodeBool:_movable forKey:@"CPMovable"]; [aCoder encodeBool:_removable forKey:@"CPRemovable"]; [aCoder encodeObject:_representedObject forKey:@"CPRepresentedObject"]; } - (id)copy { var copy = [[CPRulerMarker alloc] initWithRulerView:_ruler markerLocation:_markerLocation image:_image imageOrigin:_imageOrigin]; [copy setMovable:_movable]; [copy setRemovable:_removable]; [copy setRepresentedObject:_representedObject]; return copy; } // MARK: - Accessors - (CPRulerView)ruler { return _ruler; } // Used when adding a marker to a ruler - (void)setRulerView:(CPRulerView)aRuler { _ruler = aRuler; } - (CPImage)image { return _image; } - (void)setImage:(CPImage)anImage { _image = anImage; [_ruler setNeedsDisplay:YES]; } - (CPPoint)imageOrigin { return _imageOrigin; } - (void)setImageOrigin:(CPPoint)aPoint { _imageOrigin = aPoint; [_ruler setNeedsDisplay:YES]; } - (float)markerLocation { return _markerLocation; } - (void)setMarkerLocation:(float)location { if (_markerLocation === location) return; _markerLocation = location; // Invalidate the ruler to redraw at new position [_ruler setNeedsDisplay:YES]; } // MARK: - Geometry - (float)thicknessRequiredInRuler { if (!_image) return 0.0; // If horizontal ruler, thickness is image height. // If vertical ruler, thickness is image width. var size = [_image size]; if ([_ruler orientation] === CPHorizontalRuler) return size.height; else return size.width; } - (CPRect)imageRectInRuler { if (!_ruler || !_image) return CPMakeRect(0,0,0,0); var clientView = [_ruler clientView]; // If no client view, fallback to document view or 0 if (!clientView) clientView = [[_ruler scrollView] documentView]; var rulerBounds = [_ruler bounds], imageSize = [_image size], rect = CPMakeRect(0, 0, imageSize.width, imageSize.height); // 1. Convert Marker Location (Client Coords) to Ruler Coords var locationInRuler = 0.0; if (clientView) { // We create a point in the client view at the marker location var ptInClient = CPMakePoint(0, 0); if ([_ruler orientation] === CPHorizontalRuler) ptInClient.x = _markerLocation; else ptInClient.y = _markerLocation; // Convert that point to the ruler's coordinate system var ptInRuler = [_ruler convertPoint:ptInClient fromView:clientView]; locationInRuler = ([_ruler orientation] === CPHorizontalRuler) ? ptInRuler.x : ptInRuler.y; } // Apply user offset from CPRulerView (originOffset) // Note: The conversion above usually handles view transforms, but if CPRulerView // manually applies an extra offset property (like in the previous implementation), // we should respect it implicitly via the conversion or explicitly here. // Assuming standard view conversion handles the scroll/bounds. // 2. Position the rect based on Orientation and Image Origin // imageOrigin is the point *inside* the image that aligns with 'locationInRuler' if ([_ruler orientation] === CPHorizontalRuler) { // X: Aligned by location minus the hotspot X rect.origin.x = locationInRuler - _imageOrigin.x; // Y: This depends on the ruler's baseline. // Typically horizontal markers sit on the bottom edge (baseline). // If the image origin Y is the "tip", we position relative to that. // Usually, imageOrigin.y is 0 (bottom) or Height (top) depending on coordinate flip. // Assuming the ruler draws labels near the baseline and the marker sits on it: // Let's assume the baseline is the bottom of the ruler rect. var baseline = [_ruler baselineLocation]; rect.origin.y = baseline - _imageOrigin.y; // If flipped, adjustments might be needed, but usually image drawing handles internal flip. } else { // Vertical Ruler // Y: Aligned by location minus hotspot Y rect.origin.y = locationInRuler - _imageOrigin.y; // X: Align to right edge (baseline) var baseline = [_ruler baselineLocation]; // Width for vertical ruler rect.origin.x = baseline - _imageOrigin.x; } return rect; } // MARK: - Drawing - (void)drawRect:(CPRect)aRect { if (!_image) return; var rect = [self imageRectInRuler]; // Only draw if visible if (CPRectIntersectsRect(rect, aRect)) { // Visual feedback for dragging var opacity = _dragging ? 0.5 : 1.0; [_image drawInRect:rect fromRect:CPZeroRect operation:CPCompositeSourceOver fraction:opacity]; } } // MARK: - Event Handling - (BOOL)trackMouse:(CPEvent)anEvent adding:(BOOL)adding { if (!adding && !_movable) return NO; var clientView = [_ruler clientView], delegate = clientView; // The client view acts as the delegate usually // Delegate Check: Should we move/add? if (adding) { if ([delegate respondsToSelector:@selector(rulerView:shouldAddMarker:)] && ![delegate rulerView:_ruler shouldAddMarker:self]) return NO; } else { if ([delegate respondsToSelector:@selector(rulerView:shouldMoveMarker:)] && ![delegate rulerView:_ruler shouldMoveMarker:self]) return NO; } // If adding, we ensure the marker is in the array so it draws if (adding) [_ruler addMarker:self]; [self setDragging:YES]; var type = [anEvent type], originalLocation = _markerLocation; // Start Event Loop while (type !== CPLeftMouseUp) { // 1. Calculate new location var locationInWindow = [anEvent locationInWindow], pointInClient = [clientView convertPoint:locationInWindow fromView:nil], newLocation = ([_ruler orientation] === CPHorizontalRuler) ? pointInClient.x : pointInClient.y; // 2. Delegate: Will Move? (Snap logic usually happens here) if ([delegate respondsToSelector:@selector(rulerView:willMoveMarker:toLocation:)]) { newLocation = [delegate rulerView:_ruler willMoveMarker:self toLocation:newLocation]; } // 3. Update Location [self setMarkerLocation:newLocation]; // 4. Handle "Tearing off" (Removal visual feedback) // Check if mouse is far outside the ruler's bounds var pointInRuler = [_ruler convertPoint:locationInWindow fromView:nil]; var rulerBounds = [_ruler bounds]; // Expand bounds slightly for tolerance var expandedBounds = CPMakeRect(rulerBounds.origin.x - 20, rulerBounds.origin.y - 20, rulerBounds.size.width + 40, rulerBounds.size.height + 40); var isFarAway = !CPPointInRect(pointInRuler, expandedBounds); // You might change the cursor here to a "poof" cursor if isFarAway && _removable // 5. Get next event anEvent = [[CPApp currentEvent] window] ? [[CPApp currentEvent] window] : [CPApp keyWindow]; // fallback anEvent = [CPApp nextEventMatchingMask:CPLeftMouseDraggedMask | CPLeftMouseUpMask untilDate:[CPDate distantFuture] inMode:CPDefaultRunLoopMode dequeue:YES]; type = [anEvent type]; } [self setDragging:NO]; // Finalize var pointInRuler = [_ruler convertPoint:[anEvent locationInWindow] fromView:nil], rulerBounds = [_ruler bounds], expandedBounds = CPMakeRect(rulerBounds.origin.x - 10, rulerBounds.origin.y - 10, rulerBounds.size.width + 20, rulerBounds.size.height + 20), shouldRemove = _removable && !CPPointInRect(pointInRuler, expandedBounds); if (shouldRemove) { var allowed = YES; if ([delegate respondsToSelector:@selector(rulerView:shouldRemoveMarker:)]) allowed = [delegate rulerView:_ruler shouldRemoveMarker:self]; if (allowed) { [_ruler removeMarker:self]; // Don't call didMove or didAdd if removed return YES; } } // Success notification if (adding) { if ([delegate respondsToSelector:@selector(rulerView:didAddMarker:)]) [delegate rulerView:_ruler didAddMarker:self]; } else { if ([delegate respondsToSelector:@selector(rulerView:didMoveMarker:)]) [delegate rulerView:_ruler didMoveMarker:self]; } return YES; } @end