/* |
File: FadingTextView.m |
Abstract: A view that implements NSTextInputClient by using the Cocoa text system objects NSTextStorage, NSLayoutManager, and NSTextContainer. The view centers and displays any typed text. When the user enters a newline, the text fades out, leaving an empty field. The view also handles marked text, such as the acute accent that appears when typing the character "é" (option-e, then e). Marked characters are displayed in gray. |
Version: 1.2 |
|
Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple |
Inc. ("Apple") in consideration of your agreement to the following |
terms, and your use, installation, modification or redistribution of |
this Apple software constitutes acceptance of these terms. If you do |
not agree with these terms, please do not use, install, modify or |
redistribute this Apple software. |
|
In consideration of your agreement to abide by the following terms, and |
subject to these terms, Apple grants you a personal, non-exclusive |
license, under Apple's copyrights in this original Apple software (the |
"Apple Software"), to use, reproduce, modify and redistribute the Apple |
Software, with or without modifications, in source and/or binary forms; |
provided that if you redistribute the Apple Software in its entirety and |
without modifications, you must retain this notice and the following |
text and disclaimers in all such redistributions of the Apple Software. |
Neither the name, trademarks, service marks or logos of Apple Inc. may |
be used to endorse or promote products derived from the Apple Software |
without specific prior written permission from Apple. Except as |
expressly stated in this notice, no other rights or licenses, express or |
implied, are granted by Apple herein, including but not limited to any |
patent rights that may be infringed by your derivative works or by other |
works in which the Apple Software may be incorporated. |
|
The Apple Software is provided by Apple on an "AS IS" basis. APPLE |
MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION |
THE IMPLIED WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS |
FOR A PARTICULAR PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND |
OPERATION ALONE OR IN COMBINATION WITH YOUR PRODUCTS. |
|
IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL |
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
INTERRUPTION) ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, |
MODIFICATION AND/OR DISTRIBUTION OF THE APPLE SOFTWARE, HOWEVER CAUSED |
AND WHETHER UNDER THEORY OF CONTRACT, TORT (INCLUDING NEGLIGENCE), |
STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN ADVISED OF THE |
POSSIBILITY OF SUCH DAMAGE. |
|
Copyright (C) 2012 Apple Inc. All Rights Reserved. |
|
*/ |
|
|
#import "FadingTextView.h" |
|
static const NSTimeInterval kDefaultAnimationInterval = 0.02; |
static const NSTimeInterval kDefaultAnimationTime = 1.0; |
|
static const CGFloat kLargeWidthForTextContainer = 5e6; // large enough to not clip/wrap text, but not so large the text system stops centering for us |
static const CGFloat kTextToFrameRatio = 0.5; // tall enough for most characters to fit in the default window |
|
@implementation FadingTextView |
|
- (id)initWithFrame:(NSRect)frame { |
self = [super initWithFrame:frame]; |
if (self) { |
// Set up text attributes |
NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; |
[paragraphStyle setAlignment:NSCenterTextAlignment]; |
NSFont *font = [NSFont systemFontOfSize:ceil(NSHeight(frame) * kTextToFrameRatio)]; |
|
defaultAttributes = [[NSMutableDictionary alloc] initWithObjectsAndKeys: |
paragraphStyle, NSParagraphStyleAttributeName, |
font, NSFontAttributeName, |
nil]; |
markedAttributes = [[NSMutableDictionary alloc] initWithObjectsAndKeys: |
paragraphStyle, NSParagraphStyleAttributeName, |
font, NSFontAttributeName, |
[NSColor lightGrayColor], NSForegroundColorAttributeName, |
nil]; |
[paragraphStyle release]; |
|
// Set up the text system |
backingStore = [[NSTextStorage alloc] initWithString:@"" attributes:defaultAttributes]; |
|
layoutManager = [[NSLayoutManager alloc] init]; |
[backingStore addLayoutManager:layoutManager]; |
textContainer = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize(kLargeWidthForTextContainer, NSHeight(frame))]; |
[layoutManager addTextContainer:textContainer]; |
|
// Calculate offset from our very wide text container |
centerOffset = floor((NSWidth(frame) - kLargeWidthForTextContainer) / 2.0); |
lineHeight = floor([layoutManager defaultLineHeightForFont:font]); |
|
// Initial values for various things |
selectedRange = NSMakeRange(0, 0); |
markedRange = NSMakeRange(NSNotFound, 0); |
|
// For animation |
currentAlpha = 1.0; |
animationInterval = kDefaultAnimationInterval; |
animationTime = kDefaultAnimationTime; |
cacheImage = [[NSImage alloc] initWithSize:NSZeroSize]; |
} |
return self; |
} |
|
- (void)dealloc { |
[animateTimer invalidate]; |
[animateTimer release]; |
[cacheImage release]; |
|
[backingStore release]; |
[layoutManager release]; |
[textContainer release]; |
|
[defaultAttributes release]; |
[markedAttributes release]; |
|
[super dealloc]; |
} |
|
- (void)finalize { |
[animateTimer invalidate]; |
|
[super finalize]; |
} |
|
- (void)animate:(NSTimer *)timer { |
if (currentAlpha < 0.0) { |
[self endAnimation]; |
} else { |
currentAlpha = 1.0 + ([(NSDate *)[timer userInfo] timeIntervalSinceNow] / animationTime); |
} |
|
[self setNeedsDisplay:YES]; |
} |
|
- (void)endAnimation { |
[animateTimer invalidate]; |
[animateTimer release]; |
animateTimer = nil; |
|
[cacheImage recache]; // clear the image data |
[self recalculateDimensions]; |
lineHeight = floor([layoutManager defaultLineHeightForFont:[defaultAttributes objectForKey:NSFontAttributeName]]); |
} |
|
- (void)recalculateDimensions { |
NSRect bounds = [self bounds]; |
|
// Resize font to match new height |
NSFont *font = [NSFont systemFontOfSize:ceil(NSHeight(bounds) * kTextToFrameRatio)]; |
[defaultAttributes setValue:font forKey:NSFontAttributeName]; |
[markedAttributes setValue:font forKey:NSFontAttributeName]; |
[backingStore addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, [backingStore length])]; |
|
// Resize text container to match new height |
[textContainer setContainerSize:NSMakeSize(kLargeWidthForTextContainer, NSHeight(bounds))]; |
centerOffset = floor((NSWidth(bounds) - kLargeWidthForTextContainer) / 2.0); |
lineHeight = floor([layoutManager defaultLineHeightForFont:font]); |
|
// Pass the word along to our input context |
[[self inputContext] invalidateCharacterCoordinates]; |
} |
|
#pragma mark - |
|
- (BOOL)isFlipped { |
return YES; |
} |
|
- (void)setFrame:(NSRect)frame { |
[super setFrame:frame]; |
|
if (!animateTimer) { |
[self recalculateDimensions]; |
} |
} |
|
- (void)drawRect:(NSRect)rect { |
// First, redraw the background |
[[NSColor whiteColor] set]; |
NSRectFill(rect); |
|
NSRect bounds = [self bounds]; |
if (animateTimer) { |
// Use our cached image, which needs to be centered |
NSSize imageSize = [cacheImage size]; |
CGFloat offsetX = floor((NSWidth(bounds) - imageSize.width) / 2.0); |
CGFloat offsetY = ceil((NSHeight(bounds) - lineHeight) / 2.0); |
|
NSRect imageRect = rect; |
|
// If the image is larger than the frame, draw the correct part of it |
// Although this changes the destination rect as well, the transform accounts for that |
if (imageSize.width > NSWidth(bounds)) { |
imageRect.origin.x -= offsetX; |
} else { |
rect.origin.x += offsetX; |
} |
|
if (imageSize.height > NSHeight(bounds)) { |
imageRect.origin.y -= offsetY; |
} else { |
rect.origin.y += offsetY; |
} |
|
// Draw it! |
|
[cacheImage drawInRect:rect fromRect:imageRect operation:NSCompositeSourceOver fraction:currentAlpha respectFlipped:YES hints:nil]; |
} else { |
// Draw the text...but only what we need to! |
NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:rect inTextContainer:textContainer]; |
|
// Center everything, account for our very wide text container |
NSPoint origin; |
origin.x = centerOffset; |
origin.y = floor((NSHeight(bounds) - lineHeight) / 2.0); |
|
// Draw it! |
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:origin]; |
} |
} |
|
#pragma mark - |
|
- (BOOL)acceptsFirstResponder { |
return YES; |
} |
|
- (BOOL)becomeFirstResponder { |
return YES; |
} |
|
- (BOOL)resignFirstResponder { |
return YES; |
} |
|
- (void)keyDown:(NSEvent *)theEvent { |
[[self inputContext] handleEvent:theEvent]; |
} |
|
- (void)mouseDown:(NSEvent *)theEvent { |
[[self inputContext] handleEvent:theEvent]; |
} |
|
- (void)mouseDragged:(NSEvent *)theEvent { |
[[self inputContext] handleEvent:theEvent]; |
} |
|
- (void)mouseUp:(NSEvent *)theEvent { |
[[self inputContext] handleEvent:theEvent]; |
} |
|
- (void)insertNewline:(id)sender { |
// Save the current text to an image, so we can draw it quickly during animation |
// Then start the animation |
|
// Figure out how much room we need |
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; |
NSRect glyphRect = NSIntegralRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]); |
|
// And where the glyphs go |
NSPoint glyphOrigin; |
glyphOrigin.x = ceil((NSWidth(glyphRect) - kLargeWidthForTextContainer) / 2.0); |
glyphOrigin.y = NSHeight(glyphRect) - lineHeight; |
|
// Size the image and lock focus |
NSSize imageSize = glyphRect.size; |
if (NSEqualSizes(imageSize, NSZeroSize)) { |
imageSize = NSMakeSize(1,1); |
} |
[cacheImage setSize:imageSize]; |
[cacheImage lockFocusFlipped:YES]; |
|
// First fill the background, for proper anti-aliasing |
[[NSColor whiteColor] set]; |
NSRectFill(NSMakeRect(0, 0, imageSize.width, imageSize.height)); |
|
// Then draw the glyphs and unlock focus |
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:glyphOrigin]; |
[cacheImage unlockFocus]; |
|
// Erase the backing store; new text will stop the animation anyway |
[backingStore deleteCharactersInRange:NSMakeRange(0, [backingStore length])]; |
selectedRange = NSMakeRange(0,0); |
[self unmarkText]; |
|
// Start fully opaque |
currentAlpha = 1.0; |
|
// Create the timer... |
[animateTimer invalidate]; |
[animateTimer release]; |
animateTimer = [[NSTimer timerWithTimeInterval:animationInterval target:self selector:@selector(animate:) userInfo:[NSDate date] repeats:YES] retain]; |
|
// And start the animation! |
NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; |
[runLoop addTimer:animateTimer forMode:NSDefaultRunLoopMode]; |
[runLoop addTimer:animateTimer forMode:NSEventTrackingRunLoopMode]; // for live resize |
} |
|
- (void)deleteBackward:(id)sender { |
// Find the range to delete, handling an empty selection and the input point being at 0 |
NSRange deleteRange = selectedRange; |
if (deleteRange.length == 0) { |
if (deleteRange.location == 0) { |
return; |
} else { |
deleteRange.location -= 1; |
deleteRange.length = 1; |
|
// Make sure we handle composed characters correctly |
deleteRange = [[backingStore string] rangeOfComposedCharacterSequencesForRange:deleteRange]; |
} |
} |
|
[self deleteCharactersInRange:deleteRange]; |
} |
|
- (void)deleteForward:(id)sender { |
// Find the range to delete, handling an empty selection and the input point being at the end |
NSRange deleteRange = selectedRange; |
if (deleteRange.length == 0) { |
if (deleteRange.location == [backingStore length]) { |
return; |
} else { |
deleteRange.length = 1; |
|
// Make sure we handle composed characters correctly |
deleteRange = [[backingStore string] rangeOfComposedCharacterSequencesForRange:deleteRange]; |
} |
} |
|
[self deleteCharactersInRange:deleteRange]; |
} |
|
- (void)deleteCharactersInRange:(NSRange)range { |
// Update the marked range |
if (NSLocationInRange(NSMaxRange(range), markedRange)) { |
markedRange.length -= NSMaxRange(range) - markedRange.location; |
markedRange.location = range.location; |
} else if (markedRange.location > range.location) { |
markedRange.location -= range.length; |
} |
|
if (markedRange.length == 0) { |
[self unmarkText]; |
} |
|
// Actually delete the characters |
[backingStore deleteCharactersInRange:range]; |
selectedRange.location = range.location; |
selectedRange.length = 0; |
|
[[self inputContext] invalidateCharacterCoordinates]; |
[self setNeedsDisplay:YES]; |
} |
|
#pragma mark - |
|
- (void)doCommandBySelector:(SEL)aSelector { |
[super doCommandBySelector:aSelector]; // NSResponder's implementation will do nicely |
} |
|
- (void)insertText:(id)aString replacementRange:(NSRange)replacementRange { |
// Get a valid range |
if (animateTimer) { |
[self endAnimation]; |
replacementRange = NSMakeRange(0, 0); |
} else if (replacementRange.location == NSNotFound) { |
if (markedRange.location != NSNotFound) { |
replacementRange = markedRange; |
} else { |
replacementRange = selectedRange; |
} |
} |
|
// Add the text |
[backingStore beginEditing]; |
if ([aString isKindOfClass:[NSAttributedString class]]) { |
[backingStore replaceCharactersInRange:replacementRange withAttributedString:aString]; |
} else { |
[backingStore replaceCharactersInRange:replacementRange withString:aString]; |
} |
[backingStore setAttributes:defaultAttributes range:NSMakeRange(replacementRange.location, [aString length])]; |
[backingStore endEditing]; |
|
// Redisplay |
selectedRange = NSMakeRange([backingStore length], 0); // We don't support selection, so just place the insertion point at the end |
[self unmarkText]; |
[[self inputContext] invalidateCharacterCoordinates]; // recentering |
[self setNeedsDisplay:YES]; |
} |
|
- (void)setMarkedText:(id)aString selectedRange:(NSRange)newSelection replacementRange:(NSRange)replacementRange { |
// Get a valid range |
if (animateTimer) { |
[self endAnimation]; |
replacementRange = NSMakeRange(0, 0); |
} else if (replacementRange.location == NSNotFound) { |
if (markedRange.location != NSNotFound) { |
replacementRange = markedRange; |
} else { |
replacementRange = selectedRange; |
} |
} |
|
// Add the text |
[backingStore beginEditing]; |
if ([aString length] == 0) { |
[backingStore deleteCharactersInRange:replacementRange]; |
[self unmarkText]; |
} else { |
markedRange = NSMakeRange(replacementRange.location, [aString length]); |
if ([aString isKindOfClass:[NSAttributedString class]]) { |
[backingStore replaceCharactersInRange:replacementRange withAttributedString:aString]; |
} else { |
[backingStore replaceCharactersInRange:replacementRange withString:aString]; |
} |
[backingStore addAttributes:markedAttributes range:markedRange]; |
} |
[backingStore endEditing]; |
|
// Redisplay |
selectedRange.location = replacementRange.location + newSelection.location; // Just for now, only select the marked text |
selectedRange.length = newSelection.length; |
[[self inputContext] invalidateCharacterCoordinates]; // recentering |
[self setNeedsDisplay:YES]; |
} |
|
- (void)unmarkText { |
markedRange = NSMakeRange(NSNotFound, 0); |
[[self inputContext] discardMarkedText]; |
} |
|
- (NSRange)selectedRange { |
return selectedRange; |
} |
|
- (NSRange)markedRange { |
return markedRange; |
} |
|
- (BOOL)hasMarkedText { |
return (markedRange.location == NSNotFound ? NO : YES); |
} |
|
- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)aRange actualRange:(NSRangePointer)actualRange { |
// We choose not to adjust the range, though we have the option |
if (actualRange) { |
*actualRange = aRange; |
} |
return [backingStore attributedSubstringFromRange:aRange]; |
} |
|
- (NSArray *)validAttributesForMarkedText { |
// We only allow these attributes to be set on our marked text (plus standard attributes) |
// NSMarkedClauseSegmentAttributeName is important for CJK input, among other uses |
// NSGlyphInfoAttributeName allows alternate forms of characters |
return [NSArray arrayWithObjects:NSMarkedClauseSegmentAttributeName, NSGlyphInfoAttributeName, nil]; |
} |
|
- (NSRect)firstRectForCharacterRange:(NSRange)aRange actualRange:(NSRangePointer)actualRange { |
// Ask the layout manager |
NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:aRange actualCharacterRange:actualRange]; |
NSRect glyphRect = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]; |
glyphRect.origin.x += centerOffset; |
|
// Convert the rect to screen coordinates |
glyphRect = [self convertRectToBase:glyphRect]; |
glyphRect.origin = [[self window] convertBaseToScreen:glyphRect.origin]; |
return glyphRect; |
} |
|
- (NSUInteger)characterIndexForPoint:(NSPoint)aPoint { |
// Convert the point from screen coordinates |
NSPoint localPoint = [self convertPointFromBase:[[self window] convertScreenToBase:aPoint]]; |
localPoint.x -= centerOffset; |
|
// Ask the layout manager |
NSUInteger glyphIndex = [layoutManager glyphIndexForPoint:localPoint inTextContainer:textContainer fractionOfDistanceThroughGlyph:NULL]; |
return [layoutManager characterIndexForGlyphAtIndex:glyphIndex]; |
} |
|
- (NSAttributedString *)attributedString { |
// This method is optional, but our backing store is an attributed string anyway |
return backingStore; |
} |
|
- (NSInteger)windowLevel { |
// This method is optional but easy to implement |
return [[self window] level]; |
} |
|
- (CGFloat)fractionOfDistanceThroughGlyphForPoint:(NSPoint)aPoint { |
// This method is optional but would help with mouse-related activities, such as selection |
// Unfortunately we don't support selection |
|
// Convert the point from screen coordinates |
NSPoint localPoint = [self convertPointFromBase:[[self window] convertScreenToBase:aPoint]]; |
localPoint.x -= centerOffset; |
|
// Ask the layout manager |
CGFloat fraction = 0.5; |
[layoutManager glyphIndexForPoint:localPoint inTextContainer:textContainer fractionOfDistanceThroughGlyph:&fraction]; |
return fraction; |
} |
|
- (CGFloat)baselineDeltaForCharacterAtIndex:(NSUInteger)anIndex { |
// This method is optional but helps position other elements next to the characters, such as the box that allows you to choose which Chinese or Japanese characters you want to input. |
|
// Get the first glyph corresponding to this character |
NSUInteger glyphIndex = [layoutManager glyphIndexForCharacterAtIndex:anIndex]; |
|
if (glyphIndex != NSNotFound) { |
// Ask the layout manager's typesetter |
return [[layoutManager typesetter] baselineOffsetInLayoutManager:layoutManager glyphIndex:glyphIndex]; |
} else { |
// Fall back to the layout manager and font |
return [layoutManager defaultBaselineOffsetForFont:[defaultAttributes objectForKey:NSFontAttributeName]]; |
} |
} |
|
// No implementation of -drawsVerticallyForCharacterAtIndex:, which means all characters are assumed to be drawn horizontally. |
// This is consistent with the current behavior of NSLayoutManager. |
// If you are drawing vertically, you should implement this method. |
|
#pragma mark - |
|
- (NSString *)stringValue { |
return [[[backingStore string] retain] autorelease]; |
} |
|
- (void)setStringValue:(NSString *)aString { |
[backingStore beginEditing]; |
[backingStore replaceCharactersInRange:NSMakeRange(0, [backingStore length]) withString:aString]; |
[backingStore setAttributes:defaultAttributes range:NSMakeRange(0, [aString length])]; |
[backingStore endEditing]; |
|
[self unmarkText]; |
selectedRange = NSMakeRange([aString length], 0); |
|
if (animateTimer) { |
[self endAnimation]; |
} |
|
[[self inputContext] invalidateCharacterCoordinates]; |
[self setNeedsDisplay:YES]; |
} |
|
@synthesize animationInterval, animationTime; |
@end |