#import "OakTextView.h" #import "OakPasteboardWrapper.h" #import "OakChoiceMenu.h" #import "OakDocumentView.h" // addAuxiliaryView:atEdge: signature #import "LiveSearchView.h" #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import OAK_DEBUG_VAR(OakTextView_TextInput); OAK_DEBUG_VAR(OakTextView_Accessibility); OAK_DEBUG_VAR(OakTextView_Spelling); OAK_DEBUG_VAR(OakTextView_ViewRect); OAK_DEBUG_VAR(OakTextView_NSView); OAK_DEBUG_VAR(OakTextView_DragNDrop); OAK_DEBUG_VAR(OakTextView_MouseEvents); OAK_DEBUG_VAR(OakTextView_Macros); int32_t const NSWrapColumnWindowWidth = 0; int32_t const NSWrapColumnAskUser = -1; NSString* const kUserDefaultsWrapColumnPresetsKey = @"wrapColumnPresets"; NSString* const kUserDefaultsFontSmoothingKey = @"fontSmoothing"; NSString* const kUserDefaultsDisableAntiAliasKey = @"disableAntiAlias"; NSString* const kUserDefaultsDisableTypingPairsKey = @"disableTypingPairs"; NSString* const kUserDefaultsScrollPastEndKey = @"scrollPastEnd"; struct buffer_refresh_callback_t; @interface OakAccessibleLink : NSObject - (id)initWithTextView:(OakTextView*)textView range:(ng::range_t)range title:(NSString*)title URL:(NSString*)URL frame:(NSRect)frame; @property (nonatomic, weak) OakTextView* textView; @property (nonatomic) ng::range_t range; @property (nonatomic) NSString* title; @property (nonatomic) NSString* URL; @property (nonatomic) NSRect frame; @end @implementation OakAccessibleLink - (id)initWithTextView:(OakTextView*)textView range:(ng::range_t)range title:(NSString*)title URL:(NSString*)URL frame:(NSRect)frame { if((self = [super init])) { _textView = textView; _range = range; _title = title; _URL = URL; _frame = frame; } return self; } - (NSString*)description { return [NSString stringWithFormat:@"[%@](%@), range = %@, frame = %@", self.title, self.URL, [NSString stringWithCxxString:to_s(self.range)], NSStringFromRect(self.frame)]; } - (BOOL)isEqual:(id)object { if([object isKindOfClass:[OakAccessibleLink class]]) { OakAccessibleLink* link = (OakAccessibleLink*)object; return self.range == link.range && [self.textView isEqual:link.textView]; } return NO; } - (NSUInteger)hash { return [self.textView hash] + _range.min().index + _range.max().index; } - (BOOL)accessibilityIsIgnored { return NO; } - (NSSet*)myAccessibilityAttributeNames { static NSSet* set = [NSSet setWithArray:@[ NSAccessibilityRoleAttribute, NSAccessibilityRoleDescriptionAttribute, NSAccessibilitySubroleAttribute, NSAccessibilityParentAttribute, NSAccessibilityWindowAttribute, NSAccessibilityTopLevelUIElementAttribute, NSAccessibilityPositionAttribute, NSAccessibilitySizeAttribute, NSAccessibilityTitleAttribute, NSAccessibilityURLAttribute, ]]; return set; } - (NSArray*)accessibilityAttributeNames { static NSArray* attributes = [[self myAccessibilityAttributeNames] allObjects]; return attributes; } - (id)accessibilityAttributeValue:(NSString*)attribute { id value = nil; if([attribute isEqualToString:NSAccessibilityRoleAttribute]) { value = NSAccessibilityLinkRole; } else if([attribute isEqualToString:NSAccessibilitySubroleAttribute]) { value = NSAccessibilityTextLinkSubrole; } else if([attribute isEqualToString:NSAccessibilityRoleDescriptionAttribute]) { value = NSAccessibilityRoleDescriptionForUIElement(self); } else if([attribute isEqualToString:NSAccessibilityParentAttribute]) { value = self.textView; } else if([attribute isEqualToString:NSAccessibilityWindowAttribute] || [attribute isEqualToString:NSAccessibilityTopLevelUIElementAttribute]) { value = [self.textView accessibilityAttributeValue:attribute]; } else if([attribute isEqualToString:NSAccessibilityPositionAttribute] || [attribute isEqualToString:NSAccessibilitySizeAttribute]) { NSRect frame = self.frame; frame = [self.textView convertRect:frame toView:nil]; frame = [self.textView.window convertRectToScreen:frame]; if([attribute isEqualToString:NSAccessibilityPositionAttribute]) value = [NSValue valueWithPoint:frame.origin]; else value = [NSValue valueWithSize:frame.size]; } else if([attribute isEqualToString:NSAccessibilityTitleAttribute]) { value = self.title; } else if([attribute isEqualToString:NSAccessibilityURLAttribute]) { value = self.URL; } else { @throw [NSException exceptionWithName:NSAccessibilityException reason:[NSString stringWithFormat:@"Getting accessibility attribute not supported: %@", attribute] userInfo:nil]; } return value; } - (BOOL)accessibilityIsAttributeSettable:(NSString*)attribute { if([[self myAccessibilityAttributeNames] containsObject:attribute]) return NO; return [super accessibilityIsAttributeSettable:attribute]; } - (void)accessibilitySetValue:(id)value forAttribute:(NSString*)attribute { if([[self myAccessibilityAttributeNames] containsObject:attribute]) @throw [NSException exceptionWithName:NSAccessibilityException reason:[NSString stringWithFormat:@"Setting accessibility attribute not supported: %@", attribute] userInfo:nil]; [super accessibilitySetValue:value forAttribute:attribute]; } - (NSArray*)accessibilityParameterizedAttributeNames { return @[]; } - (id)accessibilityAttributeValue:(NSString*)attribute forParameter:(id)parameter { @throw [NSException exceptionWithName:NSAccessibilityException reason:[NSString stringWithFormat:@"Accessibility parameterized attribute not supported: %@", attribute] userInfo:nil]; } - (NSArray*)accessibilityActionNames { static NSArray* actions = nil; if(!actions) { actions = @[ NSAccessibilityPressAction, ]; } return actions; } - (NSString*)accessibilityActionDescription:(NSString*)action { return NSAccessibilityActionDescription(action); } - (void)accessibilityPerformAction:(NSString*)action { if([action isEqualToString:NSAccessibilityPressAction]) { // TODO } else { @throw [NSException exceptionWithName:NSAccessibilityException reason:[NSString stringWithFormat:@"Accessibility action not supported: %@", action] userInfo:nil]; } } - (id)accessibilityHitTest:(NSPoint)point { return self; } - (id)accessibilityFocusedUIElement { return NSAccessibilityUnignoredAncestor(self.textView); } @end typedef indexed_map_t links_t; typedef std::shared_ptr links_ptr; @interface OakTextView () { OBJC_WATCH_LEAKS(OakTextView); document::document_ptr document; theme_ptr theme; std::string fontName; CGFloat fontSize; ng::editor_ptr editor; std::shared_ptr layout; NSUInteger refreshNestCount; buffer_refresh_callback_t* callback; int32_t wrapColumn; BOOL hideCaret; NSTimer* blinkCaretTimer; NSImage* spellingDotImage; NSImage* foldingDotsImage; // ================= // = Mouse Support = // ================= NSPoint mouseDownPos; ng::index_t mouseDownIndex; NSInteger mouseDownModifierFlags; NSInteger mouseDownClickCount; OakTimer* initiateDragTimer; OakTimer* dragScrollTimer; NSDate* optionDownDate; BOOL showDragCursor; BOOL showColumnSelectionCursor; BOOL ignoreMouseDown; // set when the mouse down is the same event which caused becomeFirstResponder: BOOL delayMouseDown; // set when mouseUp: should process lastMouseDownEvent // =============== // = Drag’n’drop = // =============== ng::index_t dropPosition; ng::ranges_t markedRanges; ng::ranges_t pendingMarkedRanges; NSString* selectionString; BOOL isUpdatingSelection; NSMutableArray* macroRecordingArray; // ====================== // = Incremental Search = // ====================== NSString* liveSearchString; ng::ranges_t liveSearchAnchor; ng::ranges_t liveSearchRanges; // =================== // = Snippet Choices = // =================== OakChoiceMenu* choiceMenu; std::vector choiceVector; // ================= // = Accessibility = // ================= links_ptr _links; } + (NSArray*)dropTypes; - (void)ensureSelectionIsInVisibleArea:(id)sender; - (NSPoint)positionForWindowUnderCaret; - (void)toggleColumnSelection:(id)sender; - (void)delete:(id)sender; - (void)updateChoiceMenu:(id)sender; - (void)resetBlinkCaretTimer; - (void)reflectDocumentSize; - (void)updateSelection; - (void)updateMarkedRanges; - (void)redisplayFrom:(size_t)from to:(size_t)to; - (void)recordSelector:(SEL)aSelector withArgument:(id)anArgument; - (NSImage*)imageForRanges:(ng::ranges_t const&)ranges imageRect:(NSRect*)outRect; - (void)highlightRanges:(ng::ranges_t const&)ranges; - (NSRange)nsRangeForRange:(ng::range_t const&)range; - (ng::range_t)rangeForNSRange:(NSRange)nsRange; @property (nonatomic, readonly) ng::ranges_t const& markedRanges; @property (nonatomic) NSDate* optionDownDate; @property (nonatomic) OakTimer* initiateDragTimer; @property (nonatomic) OakTimer* dragScrollTimer; @property (nonatomic) BOOL showDragCursor; @property (nonatomic) BOOL showColumnSelectionCursor; @property (nonatomic) OakChoiceMenu* choiceMenu; @property (nonatomic) NSUInteger refreshNestCount; @property (nonatomic) LiveSearchView* liveSearchView; @property (nonatomic, copy) NSString* liveSearchString; @property (nonatomic) ng::ranges_t const& liveSearchRanges; @property (nonatomic, readonly) links_ptr links; @property (nonatomic) NSDictionary* matchCaptures; // Captures from last regexp match @property (nonatomic) BOOL needsEnsureSelectionIsInVisibleArea; @end static std::vector items_for_tab_expansion (ng::buffer_t const& buffer, ng::ranges_t const& ranges, std::string const& scopeAttributes, ng::range_t* range) { size_t caret = ranges.last().min().index; size_t line = buffer.convert(caret).line; size_t bol = buffer.begin(line); bool lastWasWordChar = false; std::string lastCharacterClass = ng::kCharacterClassUnknown; scope::scope_t const rightScope = ng::scope(buffer, ng::ranges_t(caret), scopeAttributes).right; for(size_t i = bol; i < caret; i += buffer[i].size()) { // we don’t use text::is_word_char because that function treats underscores as word characters, which is undesired, see . bool isWordChar = CFCharacterSetIsLongCharacterMember(CFCharacterSetGetPredefined(kCFCharacterSetAlphaNumeric), utf8::to_ch(buffer[i])); std::string characterClass = character_class(buffer, i); if(i == bol || lastWasWordChar != isWordChar || lastCharacterClass != characterClass || !isWordChar) { std::vector const& items = bundles::query(bundles::kFieldTabTrigger, buffer.substr(i, caret), scope::context_t(ng::scope(buffer, ng::ranges_t(i), scopeAttributes).left, rightScope)); if(!items.empty()) { if(range) *range = ng::range_t(i, caret); return items; } } lastWasWordChar = isWordChar; lastCharacterClass = characterClass; } return std::vector(); } static ng::ranges_t merge (ng::ranges_t lhs, ng::ranges_t const& rhs) { for(auto const& range : rhs) lhs.push_back(range); return lhs; } struct refresh_helper_t { typedef std::shared_ptr layout_ptr; refresh_helper_t (OakTextView* self, document::document_ptr document, ng::editor_ptr editor, layout_ptr theLayout) : _self(self), _document(document), _editor(editor), _layout(theLayout) { if(++_self.refreshNestCount == 1) { _document->sync_open(); _revision = document->buffer().revision(); _selection = editor->ranges(); _document->undo_manager().begin_undo_group(_editor->ranges()); if(layout_ptr layout = _layout.lock()) layout->begin_refresh_cycle(merge(_editor->ranges(), [_self markedRanges]), [_self liveSearchRanges]); } } static NSView* find_gutter_view (NSView* view) { for(NSView* candidate in [view subviews]) { if([candidate isKindOfClass:NSClassFromString(@"GutterView")]) return candidate; else if(NSView* res = find_gutter_view(candidate)) return res; } return nil; } ~refresh_helper_t () { if(--_self.refreshNestCount == 0) { _document->undo_manager().end_undo_group(_editor->ranges()); if(layout_ptr layout = _layout.lock()) { if(_revision == _document->buffer().revision()) { for(auto const& range : ng::highlight_ranges_for_movement(_document->buffer(), _selection, _editor->ranges())) { NSRect imageRect; NSImage* image = [_self imageForRanges:range imageRect:&imageRect]; imageRect = [[_self window] convertRectToScreen:[_self convertRect:imageRect toView:nil]]; OakShowPopOutAnimation(imageRect, image); } } if(_revision != _document->buffer().revision() || _selection != _editor->ranges()) { [_self updateMarkedRanges]; [_self updateSelection]; } auto damagedRects = layout->end_refresh_cycle(merge(_editor->ranges(), [_self markedRanges]), [_self visibleRect], [_self liveSearchRanges]); NSRect r = [[_self enclosingScrollView] documentVisibleRect]; NSSize newSize = NSMakeSize(std::max(NSWidth(r), layout->width()), std::max(NSHeight(r), layout->height())); if(!NSEqualSizes([_self frame].size, newSize)) [_self setFrameSize:newSize]; NSView* gutterView = find_gutter_view([[_self enclosingScrollView] superview]); for(auto const& rect : damagedRects) { [_self setNeedsDisplayInRect:rect]; if(gutterView) { NSRect r = rect; r.origin.x = 0; r.size.width = NSWidth([gutterView frame]); [gutterView setNeedsDisplayInRect:r]; } } if(_revision != _document->buffer().revision() || _selection != _editor->ranges() || _self.needsEnsureSelectionIsInVisibleArea) { if(_revision != _document->buffer().revision()) // FIXME document_t needs to skip work in set_revision if nothing changed. _document->set_revision(_document->buffer().revision()); [_self ensureSelectionIsInVisibleArea:nil]; [_self resetBlinkCaretTimer]; [_self updateChoiceMenu:nil]; } } _document->close(); } } private: OakTextView* _self; document::document_ptr _document; size_t _revision; ng::editor_ptr _editor; ng::ranges_t _selection; std::weak_ptr _layout; }; #define AUTO_REFRESH refresh_helper_t _dummy(self, document, editor, layout) struct buffer_refresh_callback_t : ng::callback_t { buffer_refresh_callback_t (OakTextView* textView) : textView(textView) { } void did_parse (size_t from, size_t to); void did_replace (size_t from, size_t to, std::string const& str); private: __weak OakTextView* textView; }; void buffer_refresh_callback_t::did_parse (size_t from, size_t to) { [textView redisplayFrom:from to:to]; } void buffer_refresh_callback_t::did_replace (size_t, size_t, std::string const&) { NSAccessibilityPostNotification(textView, NSAccessibilityValueChangedNotification); } static std::string shell_quote (std::vector paths) { std::transform(paths.begin(), paths.end(), paths.begin(), &path::escape); return text::join(paths, " "); } // ============================= // = OakTextView’s Find Server = // ============================= @interface OakTextViewFindServer : NSObject @property (nonatomic) OakTextView* textView; @property (nonatomic) find_operation_t findOperation; @property (nonatomic) find::options_t findOptions; @end @implementation OakTextViewFindServer + (id)findServerWithTextView:(OakTextView*)aTextView operation:(find_operation_t)anOperation options:(find::options_t)someOptions { OakTextViewFindServer* res = [OakTextViewFindServer new]; res.textView = aTextView; res.findOperation = anOperation; res.findOptions = someOptions; return res; } - (NSString*)findString { return [[OakPasteboard pasteboardWithName:NSFindPboard] current].string; } - (NSString*)replaceString { return [[OakPasteboard pasteboardWithName:OakReplacePboard] current].string; } - (void)showToolTip:(NSString*)aToolTip { OakShowToolTip(aToolTip, [self.textView positionForWindowUnderCaret]); NSAccessibilityPostNotificationWithUserInfo(self.textView, NSAccessibilityAnnouncementRequestedNotification, @{ NSAccessibilityAnnouncementKey : aToolTip }); } - (void)didFind:(NSUInteger)aNumber occurrencesOf:(NSString*)aFindString atPosition:(text::pos_t const&)aPosition wrapped:(BOOL)didWrap { NSString* format = nil; switch(aNumber) { case 0: format = @"No more %@ “%@”."; break; case 1: format = didWrap ? @"Search wrapped." : nil; break; default: format = @"%3$ld %@ “%@”."; break; } NSString* classifier = (self.findOptions & find::regular_expression) ? @"matches for" : @"occurrences of"; if(format) [self showToolTip:[NSString stringWithFormat:format, classifier, aFindString, aNumber]]; } - (void)didReplace:(NSUInteger)aNumber occurrencesOf:(NSString*)aFindString with:(NSString*)aReplacementString { static NSString* const formatStrings[2][3] = { { @"Nothing replaced (no occurrences of “%@”).", @"Replaced one occurrence of “%@”.", @"Replaced %2$ld occurrences of “%@”." }, { @"Nothing replaced (no matches for “%@”).", @"Replaced one match of “%@”.", @"Replaced %2$ld matches of “%@”." } }; NSString* format = formatStrings[(self.findOptions & find::regular_expression) ? 1 : 0][aNumber > 2 ? 2 : aNumber]; [self showToolTip:[NSString stringWithFormat:format, aFindString, aNumber]]; } @end @implementation OakTextView @synthesize initiateDragTimer, dragScrollTimer, optionDownDate, showColumnSelectionCursor, showDragCursor, choiceMenu; @synthesize markedRanges; @synthesize refreshNestCount; @synthesize liveSearchString, liveSearchRanges; // ================================= // = OakTextView Delegate Wrappers = // ================================= - (NSString*)scopeAttributes { if([self.delegate respondsToSelector:@selector(scopeAttributes)]) return [self.delegate scopeAttributes]; return @""; } // ================================= - (NSImage*)imageForRanges:(ng::ranges_t const&)ranges imageRect:(NSRect*)outRect { NSRect srcRect = NSZeroRect, visibleRect = [self visibleRect]; for(auto const& range : ranges) srcRect = NSUnionRect(srcRect, NSIntersectionRect(visibleRect, layout->rect_for_range(range.min().index, range.max().index))); NSBezierPath* clip = [NSBezierPath bezierPath]; for(auto const& rect : layout->rects_for_ranges(ranges)) [clip appendBezierPath:[NSBezierPath bezierPathWithRect:NSOffsetRect(rect, -NSMinX(srcRect), -NSMinY(srcRect))]]; NSImage* image = [[NSImage alloc] initWithSize:NSMakeSize(std::max(NSWidth(srcRect), 1), std::max(NSHeight(srcRect), 1))]; [image setFlipped:[self isFlipped]]; [image lockFocus]; [clip addClip]; CGContextRef context = (CGContextRef)[[NSGraphicsContext currentContext] graphicsPort]; CGContextTranslateCTM(context, -NSMinX(srcRect), -NSMinY(srcRect)); NSRectClip(srcRect); layout->draw(context, srcRect, [self isFlipped], false, ng::ranges_t(), ng::ranges_t(), false); [image unlockFocus]; [image setFlipped:NO]; if(outRect) *outRect = srcRect; return image; } - (void)highlightRanges:(ng::ranges_t const&)ranges { if(ranges.empty()) return; for(auto const& range : ranges) layout->remove_enclosing_folds(range.min().index, range.max().index); [self ensureSelectionIsInVisibleArea:self]; for(auto const& range : ranges) { NSRect imageRect; NSImage* image = [self imageForRanges:range imageRect:&imageRect]; imageRect = [[self window] convertRectToScreen:[self convertRect:imageRect toView:nil]]; OakShowPopOutAnimation(imageRect, image); } } - (void)scrollIndexToFirstVisible:(ng::index_t const&)visibleIndex { if(layout && visibleIndex && visibleIndex.index < document->buffer().size()) { layout->update_metrics(CGRectMake(0, CGRectGetMinY(layout->rect_at_index(visibleIndex)), CGFLOAT_MAX, NSHeight([self visibleRect]))); [self reflectDocumentSize]; CGRect rect = layout->rect_at_index(visibleIndex); if(CGRectGetMinX(rect) <= layout->margin().left) rect.origin.x = 0; if(CGRectGetMinY(rect) <= layout->margin().top) rect.origin.y = 0; rect.size = [self visibleRect].size; [self scrollRectToVisible:CGRectIntegral(rect)]; } } - (void)setDocument:(document::document_ptr const&)aDocument { if(document && aDocument && *document == *aDocument) { if(document->selection() != NULL_STR) { ng::ranges_t ranges = convert(document->buffer(), document->selection()); editor->set_selections(ranges); for(auto const& range : ranges) layout->remove_enclosing_folds(range.min().index, range.max().index); [self ensureSelectionIsInVisibleArea:self]; [self updateSelection]; } [self resetBlinkCaretTimer]; return; } if(editor) { document->buffer().remove_callback(callback); document->set_folded(layout->folded_as_string()); document->set_visible_index(layout->index_at_point([self visibleRect].origin)); delete callback; callback = NULL; delete editor->delegate(); editor->set_delegate(NULL); editor.reset(); layout.reset(); self.choiceMenu = nil; choiceVector.clear(); } if(document = aDocument) { settings_t const settings = settings_for_path(document->virtual_path(), document->file_type() + " " + to_s(self.scopeAttributes), path::parent(document->path())); editor = ng::editor_for_document(document); wrapColumn = settings.get(kSettingsWrapColumnKey, wrapColumn); layout = std::make_shared(document->buffer(), theme, settings.get(kSettingsSoftWrapKey, false), self.scrollPastEnd, wrapColumn, document->folded()); layout->set_character_mapping(settings.get(kSettingsInvisiblesMapKey, "")); if(settings.get(kSettingsShowWrapColumnKey, false)) layout->set_draw_wrap_column(true); BOOL hasFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); layout->set_is_key(hasFocus); callback = new buffer_refresh_callback_t(self); struct textview_delegate_t : ng::editor_delegate_t { textview_delegate_t (OakTextView* textView) : _self(textView) { } std::map variables_for_bundle_item (bundles::item_ptr item) { return [_self variablesForBundleItem:item]; } OakTextView* _self; }; editor->set_delegate(new textview_delegate_t(self)); editor->set_clipboard(get_clipboard(NSGeneralPboard)); editor->set_find_clipboard(get_clipboard(NSFindPboard)); editor->set_replace_clipboard(get_clipboard(OakReplacePboard)); ng::index_t visibleIndex = document->visible_index(); if(document->selection() != NULL_STR) { ng::ranges_t ranges = convert(document->buffer(), document->selection()); editor->set_selections(ranges); for(auto const& range : ranges) layout->remove_enclosing_folds(range.min().index, range.max().index); } [self reflectDocumentSize]; [self updateSelection]; if(visibleIndex && visibleIndex.index < document->buffer().size()) [self scrollIndexToFirstVisible:visibleIndex]; else [self ensureSelectionIsInVisibleArea:self]; document->buffer().add_callback(callback); [self resetBlinkCaretTimer]; [self setNeedsDisplay:YES]; _links.reset(); NSAccessibilityPostNotification(self, NSAccessibilityValueChangedNotification); } } - (id)initWithFrame:(NSRect)aRect { if(self = [super initWithFrame:aRect]) { settings_t const& settings = settings_for_path(); theme = parse_theme(bundles::lookup(settings.get(kSettingsThemeKey, NULL_STR))); fontName = settings.get(kSettingsFontNameKey, NULL_STR); fontSize = settings.get(kSettingsFontSizeKey, 11.0); theme = theme->copy_with_font_name_and_size(fontName, fontSize); _showInvisibles = settings.get(kSettingsShowInvisiblesKey, false); _scrollPastEnd = [[NSUserDefaults standardUserDefaults] boolForKey:kUserDefaultsScrollPastEndKey]; _antiAlias = ![[NSUserDefaults standardUserDefaults] boolForKey:kUserDefaultsDisableAntiAliasKey]; _fontSmoothing = (OTVFontSmoothing)[[NSUserDefaults standardUserDefaults] integerForKey:kUserDefaultsFontSmoothingKey]; spellingDotImage = [NSImage imageNamed:@"SpellingDot" inSameBundleAsClass:[self class]]; foldingDotsImage = [NSImage imageNamed:@"FoldingDots" inSameBundleAsClass:[self class]]; [self registerForDraggedTypes:[[self class] dropTypes]]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(documentWillSave:) name:@"OakDocumentNotificationWillSave" object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(documentDidSave:) name:@"OakDocumentNotificationDidSave" object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(userDefaultsDidChange:) name:NSUserDefaultsDidChangeNotification object:[NSUserDefaults standardUserDefaults]]; } return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [self setDocument:document::document_ptr()]; } - (void)documentWillSave:(NSNotification*)aNotification { NSWindow* window = [[aNotification userInfo] objectForKey:@"window"]; if(window != self.window) return; for(auto const& item : bundles::query(bundles::kFieldSemanticClass, "callback.document.will-save", [self scopeContext], bundles::kItemTypeMost, oak::uuid_t(), false)) [self performBundleItem:item]; if(document && layout) { document->set_folded(layout->folded_as_string()); document->set_visible_index(layout->index_at_point([self visibleRect].origin)); } } - (void)documentDidSave:(NSNotification*)aNotification { NSWindow* window = [[aNotification userInfo] objectForKey:@"window"]; if(window != self.window) return; for(auto const& item : bundles::query(bundles::kFieldSemanticClass, "callback.document.did-save", [self scopeContext], bundles::kItemTypeMost, oak::uuid_t(), false)) [self performBundleItem:item]; } - (void)reflectDocumentSize { if(document && layout && [self enclosingScrollView]) { NSRect r = [[self enclosingScrollView] documentVisibleRect]; layout->set_viewport_size(r.size); NSSize newSize = NSMakeSize(std::max(NSWidth(r), layout->width()), std::max(NSHeight(r), layout->height())); if(!NSEqualSizes([self frame].size, newSize)) [self setFrameSize:newSize]; } } - (void)resizeWithOldSuperviewSize:(NSSize)oldBoundsSize { if(document && layout) [self reflectDocumentSize]; else [super resizeWithOldSuperviewSize:oldBoundsSize]; } - (void)centerSelectionInVisibleArea:(id)sender { [self recordSelector:_cmd withArgument:nil]; CGRect r = layout->rect_at_index(editor->ranges().last().last); CGFloat w = NSWidth([self visibleRect]), h = NSHeight([self visibleRect]); CGFloat x = r.origin.x < w ? 0 : r.origin.x - w/2; CGFloat y = oak::cap(NSMinY([self frame]), r.origin.y - (h-r.size.height)/2, NSHeight([self frame]) - h); [self scrollRectToVisible:CGRectMake(round(x), round(y), w, h)]; } - (void)ensureSelectionIsInVisibleArea:(id)sender { self.needsEnsureSelectionIsInVisibleArea = NO; if([[self.window currentEvent] type] == NSLeftMouseDragged) // User is drag-selecting return; ng::range_t range = editor->ranges().last(); CGRect r = layout->rect_at_index(range.last); CGRect s = [self visibleRect]; CGFloat x = NSMinX(s), w = NSWidth(s); CGFloat y = NSMinY(s), h = NSHeight(s); if(range.unanchored) { CGRect a = layout->rect_at_index(range.first); CGFloat top = NSMinY(a), bottom = NSMaxY(r); if(bottom < top) { top = NSMinY(r); bottom = NSMaxY(a); } // If top or bottom of selection is outside viewport we center selection if(bottom - top < h && (top < y || y + h < bottom)) { y = top - 0.5 * (h - (bottom - top)); goto doScroll; } // If selection is taller than viewport then we don’t do anything if(bottom - top > h) return; } if(x + w - 2*r.size.width < r.origin.x) { D(DBF_OakTextView_ViewRect, bug("scroll right\n");); x = r.origin.x + 5*r.size.width - w; } else if(r.origin.x < x + 2*r.size.width) { D(DBF_OakTextView_ViewRect, bug("scroll left\n");); x = r.origin.x < w/2 ? 0 : r.origin.x - 5*r.size.width; } if(oak::cap(y + h - 1.5*r.size.height, r.origin.y, y + h + 1.5*r.size.height) == r.origin.y) // scroll down { D(DBF_OakTextView_ViewRect, bug("scroll down\n");); y = r.origin.y + 1.5*r.size.height - h; } else if(oak::cap(y - 3*r.size.height, r.origin.y, y + 0.5*r.size.height) == r.origin.y) // scroll up { D(DBF_OakTextView_ViewRect, bug("scroll up\n");); y = r.origin.y - 0.5*r.size.height; } else if(oak::cap(y, r.origin.y, y + h) != r.origin.y) // center y { y = r.origin.y - (h-r.size.height)/2; } doScroll: CGRect b = [self bounds]; x = oak::cap(NSMinX(b), x, NSMaxX(b) - w); y = oak::cap(NSMinY(b), y, NSMaxY(b) - h); NSClipView* contentView = [[self enclosingScrollView] contentView]; if([contentView respondsToSelector:@selector(_extendNextScrollRelativeToCurrentPosition)]) [contentView performSelector:@selector(_extendNextScrollRelativeToCurrentPosition)]; // Workaround for [self scrollRectToVisible:CGRectMake(round(x), round(y), w, h)]; } - (void)updateChoiceMenu:(id)sender { if(choiceVector == editor->choices()) return; self.choiceMenu = nil; choiceVector = editor->choices(); if(!choiceVector.empty()) { choiceMenu = [OakChoiceMenu new]; choiceMenu.choices = (__bridge NSArray*)((CFArrayRef)cf::wrap(choiceVector)); std::string const& currentChoice = editor->placeholder_content(); for(size_t i = choiceVector.size(); i-- > 0; ) { if(choiceVector[i] == currentChoice) choiceMenu.choiceIndex = i; } [choiceMenu showAtTopLeftPoint:[self positionForWindowUnderCaret] forView:self]; } } // ====================== // = Generic view stuff = // ====================== + (BOOL)isCompatibleWithResponsiveScrolling { return NO; } - (BOOL)acceptsFirstResponder { return YES; } - (BOOL)isFlipped { return YES; } - (BOOL)isOpaque { return YES; } - (void)redisplayFrom:(size_t)from to:(size_t)to { AUTO_REFRESH; layout->did_update_scopes(from, to); _links.reset(); } - (void)drawRect:(NSRect)aRect { if(!editor || !theme || !layout) { NSEraseRect(aRect); return; } if(theme->is_transparent()) { [[NSColor clearColor] set]; NSRectFill(aRect); } CGContextRef context = (CGContextRef)[[NSGraphicsContext currentContext] graphicsPort]; if(!self.antiAlias) CGContextSetShouldAntialias(context, false); BOOL disableFontSmoothing = NO; switch(self.fontSmoothing) { case OTVFontSmoothingDisabled: disableFontSmoothing = YES; break; case OTVFontSmoothingDisabledForDark: disableFontSmoothing = theme->is_dark(); break; case OTVFontSmoothingDisabledForDarkHiDPI: disableFontSmoothing = theme->is_dark() && [[self window] backingScaleFactor] == 2; break; } if(disableFontSmoothing) CGContextSetShouldSmoothFonts(context, false); NSImage* pdfImage = foldingDotsImage; auto foldingDotsFactory = [&pdfImage](double width, double height) -> CGImageRef { NSRect rect = NSMakeRect(0, 0, width, height); if(CGImageRef img = [pdfImage CGImageForProposedRect:&rect context:[NSGraphicsContext currentContext] hints:nil]) { if(CGImageRef res = CGImageMaskCreate(CGImageGetWidth(img), CGImageGetHeight(img), CGImageGetBitsPerComponent(img), CGImageGetBitsPerPixel(img), CGImageGetBytesPerRow(img), CGImageGetDataProvider(img), NULL, false)) return res; NSLog(@"Unable to create CGImageMask (%zu × %zu) from CGImage", CGImageGetWidth(img), CGImageGetHeight(img)); } else { NSLog(@"Unable to create CGImage (%.1f × %.1f) from %@", width, height, pdfImage); } return NULL; }; layout->draw(ng::context_t(context, [spellingDotImage CGImageForProposedRect:NULL context:[NSGraphicsContext currentContext] hints:nil], foldingDotsFactory), aRect, [self isFlipped], self.showInvisibles, merge(editor->ranges(), [self markedRanges]), liveSearchRanges); } // =============== // = NSTextInput = // =============== - (NSInteger)conversationIdentifier { return (NSInteger)self; } // ================== // = Accented input = // ================== - (NSRange)nsRangeForRange:(ng::range_t const&)range { //TODO this and the next method could use some optimization using an interval tree // similar to basic_tree_t for conversion between UTF-8 and UTF-16 indexes. // Currently poor performance for large documents (O(N)) would then get to O(log(N)) // Also currently copy of whole text is created here, which is not optimal std::string const text = document->buffer().substr(0, range.max().index); char const* base = text.data(); NSUInteger location = utf16::distance(base, base + range.min().index); NSUInteger length = utf16::distance(base + range.min().index, base + range.max().index); return NSMakeRange(location, length); } - (ng::range_t)rangeForNSRange:(NSRange)nsRange { std::string const text = editor->as_string(); char const* base = text.data(); ng::index_t from = utf16::advance(base, nsRange.location, base + text.size()) - base; ng::index_t to = utf16::advance(base + from.index, nsRange.length, base + text.size()) - base; return ng::range_t(from, to); } - (void)setMarkedText:(id)aString selectedRange:(NSRange)aRange { D(DBF_OakTextView_TextInput, bug("‘%s’ %s\n", to_s([aString description]).c_str(), [NSStringFromRange(aRange) UTF8String]);); if(![aString isKindOfClass:[NSString class]]) { if([aString respondsToSelector:@selector(string)]) aString = [aString string]; else if([aString respondsToSelector:@selector(description)]) aString = [aString description]; else aString = @""; } AUTO_REFRESH; if(!markedRanges.empty()) editor->set_selections(markedRanges); markedRanges = ng::ranges_t(); editor->insert(to_s([aString description]), true); if([aString length] != 0) markedRanges = editor->ranges(); pendingMarkedRanges = markedRanges; ng::ranges_t sel; for(auto const& range : editor->ranges()) { std::string const str = document->buffer().substr(range.min().index, range.max().index); char const* base = str.data(); size_t from = utf16::advance(base, aRange.location, base + str.size()) - base; size_t to = utf16::advance(base, aRange.location + aRange.length, base + str.size()) - base; sel.push_back(ng::range_t(range.min() + from, range.min() + to)); } editor->set_selections(sel); } - (NSRange)selectedRange { NSRange res = [self nsRangeForRange:editor->ranges().last()]; D(DBF_OakTextView_TextInput, bug("%s\n", [NSStringFromRange(res) UTF8String]);); return res; } - (NSRange)markedRange { D(DBF_OakTextView_TextInput, bug("%s\n", to_s(markedRanges).c_str());); if(markedRanges.empty()) return NSMakeRange(NSNotFound, 0); return [self nsRangeForRange:markedRanges.last()]; } - (void)unmarkText { D(DBF_OakTextView_TextInput, bug("\n");); AUTO_REFRESH; markedRanges = pendingMarkedRanges = ng::ranges_t(); } - (BOOL)hasMarkedText { D(DBF_OakTextView_TextInput, bug("%s\n", BSTR(!markedRanges.empty()));); return !markedRanges.empty(); } - (NSArray*)validAttributesForMarkedText { D(DBF_OakTextView_TextInput, bug("\n");); return [NSArray array]; } - (void)updateMarkedRanges { if(!markedRanges.empty() && pendingMarkedRanges.empty()) [[NSTextInputContext currentInputContext] discardMarkedText]; markedRanges = pendingMarkedRanges; pendingMarkedRanges = ng::ranges_t(); } // ===================== // = Dictionary pop-up = // ===================== - (NSString*)string { D(DBF_OakTextView_TextInput, bug("\n");); return [NSString stringWithCxxString:editor->as_string()]; // While undocumented (), this is required in Lion to work with the dictionary implementation (⌃⌘D) } - (NSUInteger)characterIndexForPoint:(NSPoint)thePoint { NSPoint p = [self convertPoint:[[self window] convertRectFromScreen:(NSRect){ thePoint, NSZeroSize }].origin fromView:nil]; std::string const text = editor->as_string(); size_t index = layout->index_at_point(p).index; D(DBF_OakTextView_TextInput, bug("%s → %zu\n", [NSStringFromPoint(thePoint) UTF8String], index);); return utf16::distance(text.data(), text.data() + index); } - (NSAttributedString*)attributedSubstringFromRange:(NSRange)theRange { ng::range_t const& r = [self rangeForNSRange:theRange]; size_t from = r.min().index, to = r.max().index; if(CFMutableAttributedStringRef res = CFAttributedStringCreateMutable(kCFAllocatorDefault, 0)) { std::map scopes = document->buffer().scopes(from, to); for(auto pair = scopes.begin(); pair != scopes.end(); ) { styles_t const& styles = theme->styles_for_scope(pair->second); size_t i = from + pair->first; size_t j = ++pair != scopes.end() ? from + pair->first : to; if(CFMutableAttributedStringRef str = CFAttributedStringCreateMutable(kCFAllocatorDefault, 0)) { CFAttributedStringReplaceString(str, CFRangeMake(0, 0), cf::wrap(document->buffer().substr(i, j))); CFAttributedStringSetAttribute(str, CFRangeMake(0, CFAttributedStringGetLength(str)), kCTFontAttributeName, styles.font()); CFAttributedStringSetAttribute(str, CFRangeMake(0, CFAttributedStringGetLength(str)), kCTForegroundColorAttributeName, styles.foreground()); if(styles.underlined()) CFAttributedStringSetAttribute(str, CFRangeMake(0, CFAttributedStringGetLength(str)), kCTUnderlineStyleAttributeName, cf::wrap(0x1|kCTUnderlinePatternSolid)); CFAttributedStringReplaceAttributedString(res, CFRangeMake(CFAttributedStringGetLength(res), 0), str); CFRelease(str); } } return (NSAttributedString*)CFBridgingRelease(res); } return nil; } - (NSRect)firstRectForCharacterRange:(NSRange)theRange { ng::range_t const& r = [self rangeForNSRange:theRange]; NSRect rect = [[self window] convertRectToScreen:[self convertRect:layout->rect_at_index(r.min()) toView:nil]]; D(DBF_OakTextView_TextInput, bug("%s → %s\n", [NSStringFromRange(theRange) UTF8String], [NSStringFromRect(rect) UTF8String]);); return rect; } - (void)doCommandBySelector:(SEL)aSelector { D(DBF_OakTextView_TextInput, bug("%s\n", sel_getName(aSelector));); AUTO_REFRESH; [self tryToPerform:aSelector with:self]; } - (BOOL)respondsToSelector:(SEL)aSelector { // Do not handle cancelOperation: (as complete:) when caret is not on a word (instead give next responder a chance) if(aSelector == @selector(cancelOperation:) && ng::word_at(document->buffer(), editor->ranges().last()).empty()) return NO; return [super respondsToSelector:aSelector]; } - (void)cancelOperation:(id)sender { [self complete:sender]; } // ================= // = Accessibility = // ================= - (BOOL)accessibilityIsIgnored { return NO; } #define ATTR(attr) NSAccessibility##attr##Attribute #define PATTR(attr) NSAccessibility##attr##ParameterizedAttribute #define ATTREQ_(attribute_) [attribute isEqualToString:attribute_] #define HANDLE_ATTR(attr) else if(ATTREQ_(ATTR(attr))) #define HANDLE_PATTR(attr) else if(ATTREQ_(PATTR(attr))) - (NSSet*)myAccessibilityAttributeNames { static NSSet* set = [NSSet setWithArray:@[ ATTR(Role), ATTR(Value), ATTR(InsertionPointLineNumber), ATTR(NumberOfCharacters), ATTR(SelectedText), ATTR(SelectedTextRange), ATTR(SelectedTextRanges), ATTR(VisibleCharacterRange), ATTR(Children), ]]; return set; } - (NSArray*)accessibilityAttributeNames { static NSArray* attributes = [[[self myAccessibilityAttributeNames] setByAddingObjectsFromArray:[super accessibilityAttributeNames]] allObjects]; return attributes; } - (id)accessibilityAttributeValue:(NSString*)attribute { D(DBF_OakTextView_Accessibility, bug("%s\n", to_s(attribute).c_str());); id ret = nil; ng::buffer_t const& buffer = document->buffer(); if(false) { } HANDLE_ATTR(Role) { ret = NSAccessibilityTextAreaRole; } HANDLE_ATTR(Value) { ret = [NSString stringWithCxxString:editor->as_string()]; } HANDLE_ATTR(InsertionPointLineNumber) { ret = [NSNumber numberWithUnsignedLong:layout->softline_for_index(editor->ranges().last().min())]; } HANDLE_ATTR(NumberOfCharacters) { ret = [NSNumber numberWithUnsignedInteger:[self nsRangeForRange:ng::range_t(0, buffer.size())].length]; } HANDLE_ATTR(SelectedText) { ng::range_t const selection = editor->ranges().last(); std::string const text = buffer.substr(selection.min().index, selection.max().index); ret = [NSString stringWithCxxString:text]; } HANDLE_ATTR(SelectedTextRange) { ret = [NSValue valueWithRange:[self nsRangeForRange:editor->ranges().last()]]; } HANDLE_ATTR(SelectedTextRanges) { ng::ranges_t const ranges = editor->ranges(); NSMutableArray* nsRanges = [NSMutableArray arrayWithCapacity:ranges.size()]; for(auto const& range : ranges) [nsRanges addObject:[NSValue valueWithRange:[self nsRangeForRange:range]]]; ret = nsRanges; } HANDLE_ATTR(VisibleCharacterRange) { NSRect visibleRect = [self visibleRect]; CGPoint startPoint = NSMakePoint(NSMinX(visibleRect), NSMaxY(visibleRect)); CGPoint endPoint = NSMakePoint(NSMinX(visibleRect), NSMinY(visibleRect)); ng::range_t visibleRange(layout->index_at_point(startPoint), layout->index_at_point(endPoint)); visibleRange = visibleRange.sorted(); visibleRange.last = layout->index_below(visibleRange.last); return [NSValue valueWithRange:[self nsRangeForRange:visibleRange]]; } HANDLE_ATTR(Children) { NSMutableArray* links = [NSMutableArray array]; std::shared_ptr links_ = self.links; for(auto const& pair : *links_) [links addObject:pair.second]; return links; } else { ret = [super accessibilityAttributeValue:attribute]; } return ret; } - (BOOL)accessibilityIsAttributeSettable:(NSString*)attribute { static NSArray* settable = @[ ATTR(Value), ATTR(SelectedText), ATTR(SelectedTextRange), ATTR(SelectedTextRanges) ]; if([[self myAccessibilityAttributeNames] containsObject:attribute]) return [settable containsObject:attribute]; return [super accessibilityIsAttributeSettable:attribute]; } - (void)accessibilitySetValue:(id)value forAttribute:(NSString*)attribute { D(DBF_OakTextView_Accessibility, bug("%s <- %s\n", to_s(attribute).c_str(), to_s([value description]).c_str());); if(false) { } HANDLE_ATTR(Value) { AUTO_REFRESH; document->set_content(to_s((NSString*)value)); } HANDLE_ATTR(SelectedText) { AUTO_REFRESH; editor->insert(to_s((NSString*)value)); } HANDLE_ATTR(SelectedTextRange) { [self accessibilitySetValue:@[ value ] forAttribute:NSAccessibilitySelectedTextRangesAttribute]; } HANDLE_ATTR(SelectedTextRanges) { NSArray* nsRanges = (NSArray*)value; ng::ranges_t ranges; for(NSValue* nsRangeValue in nsRanges) ranges.push_back([self rangeForNSRange:[nsRangeValue rangeValue]]); AUTO_REFRESH; editor->set_selections(ranges); } else { [super accessibilitySetValue:value forAttribute:attribute]; } } - (NSUInteger)accessibilityArrayAttributeCount:(NSString* )attribute { if([attribute isEqualToString:NSAccessibilityChildrenAttribute]) { return self.links->size(); } else { return [super accessibilityArrayAttributeCount:attribute]; } } - (NSArray*)accessibilityArrayAttributeValues:(NSString*)attribute index:(NSUInteger)index maxCount:(NSUInteger)maxCount { if([attribute isEqualToString:NSAccessibilityChildrenAttribute]) { links_ptr const links = self.links; NSMutableArray* values = [NSMutableArray arrayWithCapacity:maxCount]; for(auto it = links->nth(index); maxCount && it != links->end(); ++it, --maxCount) [values addObject:it->second]; return values; } else { return [super accessibilityArrayAttributeValues:attribute index:index maxCount:maxCount]; } } - (NSUInteger)accessibilityIndexOfChild:(id)child { if([child isKindOfClass:[OakAccessibleLink class]]) { OakAccessibleLink* link = (OakAccessibleLink* )child; links_ptr const links = self.links; auto it = links->find(link.range.max().index); if(it != links->end()) return it.index(); else return NSNotFound; } else { return [super accessibilityIndexOfChild:child]; } } - (NSArray*)accessibilityParameterizedAttributeNames { static NSArray* attributes = nil; if(!attributes) { NSSet* set = [NSSet setWithArray:@[ PATTR(LineForIndex), PATTR(RangeForLine), PATTR(StringForRange), PATTR(RangeForPosition), PATTR(RangeForIndex), PATTR(BoundsForRange), // PATTR(RTFForRange), // PATTR(StyleRangeForIndex), PATTR(AttributedStringForRange), ]]; attributes = [[set setByAddingObjectsFromArray:[super accessibilityParameterizedAttributeNames]] allObjects]; } return attributes; } - (id)accessibilityAttributeValue:(NSString*)attribute forParameter:(id)parameter { D(DBF_OakTextView_Accessibility, bug("%s(%s)\n", to_s(attribute).c_str(), to_s([parameter description]).c_str());); id ret = nil; if(false) { } HANDLE_PATTR(LineForIndex) { size_t index = [((NSNumber*)parameter) unsignedLongValue]; index = [self rangeForNSRange:NSMakeRange(index, 0)].min().index; size_t line = layout->softline_for_index(index); ret = [NSNumber numberWithUnsignedLong:line]; } HANDLE_PATTR(RangeForLine) { size_t line = [((NSNumber*)parameter) unsignedLongValue]; ng::range_t const range = layout->range_for_softline(line); ret = [NSValue valueWithRange:[self nsRangeForRange:range]]; } HANDLE_PATTR(StringForRange) { ng::range_t range = [self rangeForNSRange:[((NSValue*)parameter) rangeValue]]; ret = [NSString stringWithCxxString:editor->as_string(range.min().index, range.max().index)]; } HANDLE_PATTR(RangeForPosition) { NSPoint point = [((NSValue*)parameter) pointValue]; point = [[self window] convertRectFromScreen:(NSRect){ point, NSZeroSize }].origin; point = [self convertPoint:point fromView:nil]; size_t index = layout->index_at_point(point).index; index = document->buffer().sanitize_index(index); size_t const length = document->buffer()[index].length(); ret = [NSValue valueWithRange:[self nsRangeForRange:ng::range_t(index, index + length)]]; } HANDLE_PATTR(RangeForIndex) { size_t index = [((NSNumber*)parameter) unsignedLongValue]; index = [self rangeForNSRange:NSMakeRange(index, 0)].min().index; index = document->buffer().sanitize_index(index); size_t const length = document->buffer()[index].length(); ret = [NSValue valueWithRange:[self nsRangeForRange:ng::range_t(index, index + length)]]; } HANDLE_PATTR(BoundsForRange) { ng::range_t range = [self rangeForNSRange:[((NSValue*)parameter) rangeValue]]; NSRect rect = layout->rect_for_range(range.min().index, range.max().index, true); rect = [self convertRect:rect toView:nil]; rect = [[self window] convertRectToScreen:rect]; ret = [NSValue valueWithRect:rect]; // } HANDLE_PATTR(RTFForRange) { // TODO // } HANDLE_PATTR(StyleRangeForIndex) { // TODO } HANDLE_PATTR(AttributedStringForRange) { #define TATTR(attr) NSAccessibility##attr##TextAttribute #define TKEY(key) NSAccessibility##key##Key NSRange aRange = [((NSValue *)parameter) rangeValue]; ng::range_t const range = [self rangeForNSRange:aRange]; size_t const from = range.min().index, to = range.max().index; std::string const text = editor->as_string(from, to); NSMutableAttributedString* res = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithCxxString:text]]; // Add style std::map scopes = document->buffer().scopes(from, to); NSRange runRange = NSMakeRange(0, 0); for(auto pair = scopes.begin(); pair != scopes.end(); ) { styles_t const& styles = theme->styles_for_scope(pair->second); size_t i = pair->first; size_t j = ++pair != scopes.end() ? pair->first : to - from; runRange.location += runRange.length; runRange.length = utf16::distance(text.data() + i, text.data() + j); NSFont* font = (__bridge NSFont *)styles.font(); NSMutableDictionary* attributes = [NSMutableDictionary dictionaryWithCapacity:4]; [attributes addEntriesFromDictionary:@{ TATTR(Font): @{ TKEY(FontName): [font fontName], TKEY(FontFamily): [font familyName], TKEY(VisibleName): [font displayName], TKEY(FontSize): @([font pointSize]), }, TATTR(ForegroundColor): (__bridge id)styles.foreground(), TATTR(BackgroundColor): (__bridge id)styles.background(), }]; if(styles.underlined()) attributes[TATTR(Underline)] = @(NSUnderlineStyleSingle | NSUnderlinePatternSolid); // TODO is this always so? [res setAttributes:attributes range:runRange]; } // Add links const links_ptr links = self.links; auto lbegin = links->upper_bound(from); auto lend = links->lower_bound(to); if(lend != links->end() && to >= lend->second.range.min().index) ++lend; std::for_each(lbegin, lend, [=](links_t::iterator::value_type const& pair){ ng::range_t range = pair.second.range; range.first = oak::cap(ng::index_t(from), range.min(), ng::index_t(to)); range.last = oak::cap(ng::index_t(from), range.max(), ng::index_t(to)); if(!range.empty()) { range.first.index -= from; range.last.index -= from; NSRange linkRange; linkRange.location = utf16::distance(text.data(), text.data() + range.first.index); linkRange.length = utf16::distance(text.data() + range.first.index, text.data() + range.last.index); [res addAttribute:TATTR(Link) value:pair.second range:linkRange]; } }); // Add misspellings std::map misspellings = document->buffer().misspellings(from, to); auto pair = misspellings.begin(); auto const end = misspellings.end(); ASSERT((pair == end) || pair->second); runRange = NSMakeRange(0, 0); if(pair != end) runRange.length = utf16::distance(text.data(), text.data() + pair->first); while(pair != end) { ASSERT(pair != end); ASSERT(pair->second); // assert(runRange.location + runRange.length ~ pair->first) size_t const i = pair->first; size_t const j = (++pair != end) ? pair->first : to - from; ASSERT((pair == end) || (!pair->second)); runRange.location += runRange.length; runRange.length = utf16::distance(text.data() + i, text.data() + j); [res addAttribute:TATTR(Misspelled) value:@(true) range:runRange]; [res addAttribute:@"AXMarkedMisspelled" value:@(true) range:runRange]; if((pair != end) && (++pair != end)) { ASSERT(pair->second); size_t const k = pair->first; runRange.location += runRange.length; runRange.length = utf16::distance(text.data() + j, text.data() + k); } } // Add text language NSString* lang = [NSString stringWithCxxString:document->buffer().spelling_language()]; [res addAttribute:@"AXNaturalLanguageText" value:lang range:NSMakeRange(0, [res length])]; return res; #undef TATTR #undef TKEY } else { ret = [super accessibilityAttributeValue:attribute forParameter:parameter]; } return ret; } - (id)accessibilityHitTest:(NSPoint)screenPoint { NSPoint point = [self convertRect:[self.window convertRectFromScreen:NSMakeRect(screenPoint.x, screenPoint.y, 0, 0)] fromView:nil].origin; ng::index_t index = layout->index_at_point(point); const links_ptr links = self.links; auto it = links->lower_bound(index.index); if(it != links->end() && it->second.range.min() <= index) { OakAccessibleLink* link = it->second; if(NSMouseInRect(point, link.frame, YES)) return [link accessibilityHitTest:screenPoint]; } return self; } - (links_ptr)links { if(!_links) { links_ptr links(new links_t()); scope::selector_t linkSelector = "markup.underline.link"; ng::buffer_t const& buffer = document->buffer(); std::map scopes = buffer.scopes(0, buffer.size()); for(auto pair = scopes.begin(); pair != scopes.end(); ) { if(!linkSelector.does_match(pair->second)) { ++pair; continue; } size_t i = pair->first; size_t j = ++pair != scopes.end() ? pair->first : buffer.size(); NSString* title = [NSString stringWithCxxString:buffer.substr(i, j)]; NSRect frame = NSRectFromCGRect(layout->rect_for_range(i, j)); ng::range_t range(i, j); OakAccessibleLink* link = [[OakAccessibleLink alloc] initWithTextView:self range:range title:title URL:nil frame:frame]; links->set(j, link); } _links = links; } return _links; } - (void)updateZoom:(id)sender { size_t const index = editor->ranges().last().min().index; NSRect selectedRect = layout->rect_at_index(index, false); selectedRect = [self convertRect:selectedRect toView:nil]; selectedRect = [[self window] convertRectToScreen:selectedRect]; NSRect viewRect = [self convertRect:[self visibleRect] toView:nil]; viewRect = [[self window] convertRectToScreen:viewRect]; viewRect.origin.y = [[NSScreen mainScreen] frame].size.height - (viewRect.origin.y + viewRect.size.height); selectedRect.origin.y = [[NSScreen mainScreen] frame].size.height - (selectedRect.origin.y + selectedRect.size.height); UAZoomChangeFocus(&viewRect, &selectedRect, kUAZoomFocusTypeInsertionPoint); } #undef ATTR #undef PATTR #undef ATTREQ_ #undef HANDLE_ATTR #undef HANDLE_PATTR // ================ // = Bundle Items = // ================ // FIXME copy/paste from bundles/src/query.cc static std::string format_bundle_item_title (std::string title, bool hasSelection) { static std::string const kSelectionSubString = " / Selection"; std::string::size_type pos = title.find(kSelectionSubString); if(pos == 0 || pos == std::string::npos) return title; if(hasSelection) { std::string::size_type from = title.rfind(' ', pos - 1); if(from == std::string::npos) return title.erase(0, pos + 3); return title.erase(from + 1, pos + 3 - from - 1); } return title.erase(pos, kSelectionSubString.size()); } - (std::map)variablesForBundleItem:(bundles::item_ptr const&)item { std::map res = oak::basic_environment(); res << document->document_variables() << editor->editor_variables(to_s([self scopeAttributes])); if(item) res << item->bundle_variables(); if(auto themeItem = (theme ? bundles::lookup(theme->uuid()) : bundles::item_ptr())) { if(!themeItem->paths().empty()) res["TM_CURRENT_THEME_PATH"] = themeItem->paths().back(); } if([self.delegate respondsToSelector:@selector(variables)]) res << [self.delegate variables]; res = bundles::scope_variables(res, [self scopeContext]); res = variables_for_path(res, document->virtual_path(), [self scopeContext].right, path::parent(document->path())); return res; } - (std::map)variables { return [self variablesForBundleItem:bundles::item_ptr()]; } - (void)performBundleItem:(bundles::item_ptr)item { crash_reporter_info_t info(text::format("%s %s", sel_getName(_cmd), item->full_name().c_str())); // D(DBF_OakTextView_BundleItems, bug("%s\n", anItem->full_name().c_str());); AUTO_REFRESH; switch(item->kind()) { case bundles::kItemTypeSnippet: { [self recordSelector:@selector(insertSnippetWithOptions:) withArgument:ns::to_dictionary(item->plist())]; editor->snippet_dispatch(item->plist(), [self variablesForBundleItem:item]); } break; case bundles::kItemTypeCommand: { [self recordSelector:@selector(executeCommandWithOptions:) withArgument:ns::to_dictionary(item->plist())]; auto command = parse_command(item); command.name = format_bundle_item_title(command.name, self.hasSelection); if([self.delegate respondsToSelector:@selector(bundleItemPreExec:completionHandler:)]) { [self.delegate bundleItemPreExec:command.pre_exec completionHandler:^(BOOL success){ if(success) { AUTO_REFRESH; document::run(command, document->buffer(), editor->ranges(), document, [self variablesForBundleItem:item]); } }]; } else { command.pre_exec = pre_exec::nop; document::run(command, document->buffer(), editor->ranges(), document, [self variablesForBundleItem:item]); } } break; case bundles::kItemTypeMacro: { [self recordSelector:@selector(playMacroWithOptions:) withArgument:ns::to_dictionary(item->plist())]; editor->macro_dispatch(item->plist(), [self variablesForBundleItem:item]); } break; case bundles::kItemTypeGrammar: { document->set_file_type(item->value_for_field(bundles::kFieldGrammarScope)); file::set_type(document->virtual_path(), item->value_for_field(bundles::kFieldGrammarScope)); } break; } } - (void)applicationDidBecomeActiveNotification:(NSNotification*)aNotification { for(auto const& item : bundles::query(bundles::kFieldSemanticClass, "callback.application.did-activate", [self scopeContext], bundles::kItemTypeMost, oak::uuid_t(), false)) [self performBundleItem:item]; } - (void)applicationDidResignActiveNotification:(NSNotification*)aNotification { for(auto const& item : bundles::query(bundles::kFieldSemanticClass, "callback.application.did-deactivate", [self scopeContext], bundles::kItemTypeMost, oak::uuid_t(), false)) [self performBundleItem:item]; } // ============ // = Key Down = // ============ static plist::dictionary_t KeyBindings; static plist::dictionary_t const* KeyEventContext = &KeyBindings; static plist::any_t normalize_potential_dictionary (plist::any_t const& action) { if(plist::dictionary_t const* dict = boost::get(&action)) { plist::dictionary_t res; for(auto const& pair : *dict) res.emplace(ns::normalize_event_string(pair.first), normalize_potential_dictionary(pair.second)); return res; } return action; } typedef std::multimap action_to_key_t; static void update_menu_key_equivalents (NSMenu* menu, action_to_key_t const& actionToKey) { for(NSMenuItem* item in [menu itemArray]) { SEL action = [item action]; action_to_key_t::const_iterator it = actionToKey.find(sel_getName(action)); if(it != actionToKey.end() && OakIsEmptyString([item keyEquivalent])) [item setKeyEquivalentCxxString:it->second]; update_menu_key_equivalents([item submenu], actionToKey); } } + (void)initialize { static dispatch_once_t onceToken = 0; dispatch_once(&onceToken, ^{ static std::string const KeyBindingLocations[] = { oak::application_t::support("KeyBindings.dict"), oak::application_t::path("Contents/Resources/KeyBindings.dict"), path::join(path::home(), "Library/KeyBindings/DefaultKeyBinding.dict"), "/Library/KeyBindings/DefaultKeyBinding.dict", "/System/Library/Frameworks/AppKit.framework/Resources/StandardKeyBinding.dict", }; for(auto const& path : KeyBindingLocations) { for(auto const& pair : plist::load(path)) KeyBindings.emplace(ns::normalize_event_string(pair.first), normalize_potential_dictionary(pair.second)); } action_to_key_t actionToKey; for(auto const& pair : KeyBindings) { if(std::string const* selector = boost::get(&pair.second)) actionToKey.emplace(*selector, pair.first); } update_menu_key_equivalents([NSApp mainMenu], actionToKey); [[NSUserDefaults standardUserDefaults] registerDefaults:@{ kUserDefaultsFontSmoothingKey : @(OTVFontSmoothingDisabledForDarkHiDPI), kUserDefaultsWrapColumnPresetsKey : @[ @40, @80 ], }]; }); [NSApp registerServicesMenuSendTypes:@[ NSStringPboardType ] returnTypes:@[ NSStringPboardType ]]; } // ====================== // = NSServicesRequests = // ====================== - (id)validRequestorForSendType:(NSString*)sendType returnType:(NSString*)returnType { if([sendType isEqual:NSStringPboardType] && [self hasSelection] && !macroRecordingArray) return self; if(!sendType && [returnType isEqual:NSStringPboardType] && !macroRecordingArray) return self; return [super validRequestorForSendType:sendType returnType:returnType]; } - (BOOL)writeSelectionToPasteboard:(NSPasteboard*)pboard types:(NSArray*)types { BOOL res = NO; if([self hasSelection] && [types containsObject:NSStringPboardType]) { std::vector v; ng::ranges_t const ranges = ng::dissect_columnar(document->buffer(), editor->ranges()); for(auto const& range : ranges) v.push_back(document->buffer().substr(range.min().index, range.max().index)); [pboard declareTypes:@[ NSStringPboardType ] owner:nil]; res = [pboard setString:[NSString stringWithCxxString:text::join(v, "\n")] forType:NSStringPboardType]; } return res; } - (BOOL)readSelectionFromPasteboard:(NSPasteboard*)pboard { if(NSString* str = [pboard stringForType:[pboard availableTypeFromArray:@[ @"public.plain-text" ]]]) { AUTO_REFRESH; editor->insert(to_s(str)); return YES; } return NO; } // ====================== - (void)handleKeyBindingAction:(plist::any_t const&)anAction { AUTO_REFRESH; if(std::string const* selector = boost::get(&anAction)) { KeyEventContext = &KeyBindings; [self doCommandBySelector:NSSelectorFromString([NSString stringWithCxxString:*selector])]; } else if(plist::array_t const* actions = boost::get(&anAction)) { KeyEventContext = &KeyBindings; std::vector selectors; for(auto const& it : *actions) { if(std::string const* selector = boost::get(&it)) selectors.push_back(*selector); } for(size_t i = 0; i < selectors.size(); ++i) { if(selectors[i] == "insertText:" && i+1 < selectors.size()) [self insertText:[NSString stringWithCxxString:selectors[++i]]]; else [self doCommandBySelector:NSSelectorFromString([NSString stringWithCxxString:selectors[i]])]; } } else if(plist::dictionary_t const* nested = boost::get(&anAction)) { KeyEventContext = nested; } } - (BOOL)performKeyEquivalent:(NSEvent*)anEvent { BOOL hasFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); if(!hasFocus && ([[[self window] firstResponder] isKindOfClass:[self class]] || [[[self window] firstResponder] isKindOfClass:NSClassFromString(@"OakKeyEquivalentView")])) return NO; D(DBF_OakTextView_TextInput, bug("%s\n", [[anEvent description] UTF8String]);); std::string const eventString = to_s(anEvent); if(KeyEventContext != &KeyBindings) { plist::dictionary_t::const_iterator pair = KeyEventContext->find(eventString); if(pair != KeyEventContext->end()) return [self handleKeyBindingAction:pair->second], YES; } std::vector const& items = bundles::query(bundles::kFieldKeyEquivalent, eventString, [self scopeContext]); if(!items.empty()) { if(bundles::item_ptr item = OakShowMenuForBundleItems(items, [self positionForWindowUnderCaret])) [self performBundleItem:item]; return YES; } static std::string const kBackwardDelete = "\x7F"; static std::string const kForwardDelete = "\uF728"; static std::string const kUpArrow = "\uF700"; static std::string const kDownArrow = "\uF701"; static std::string const kLeftArrow = "\uF702"; static std::string const kRightArrow = "\uF703"; // these never reach ‘keyDown:’ (tested on 10.5.8) static std::set const SpecialKeys = { "^" + kBackwardDelete, "^" + kForwardDelete, "^" + kUpArrow, "^" + kDownArrow, "^" + kLeftArrow, "^" + kRightArrow, "^$" + kUpArrow, "^$" + kDownArrow, "^$" + kLeftArrow, "^$" + kRightArrow, "^~" + kUpArrow, "^~" + kDownArrow, "^~" + kLeftArrow, "^~" + kRightArrow, "^~$" + kUpArrow, "^~$" + kDownArrow, "^~$" + kLeftArrow, "^~$" + kRightArrow, }; if(SpecialKeys.find(eventString) != SpecialKeys.end()) { plist::dictionary_t::const_iterator pair = KeyEventContext->find(eventString); if(pair != KeyEventContext->end()) return [self handleKeyBindingAction:pair->second], YES; } return NO; } - (void)interpretKeyEvents:(NSArray*)someEvents { AUTO_REFRESH; if([self hasMarkedText]) return [super interpretKeyEvents:someEvents]; for(NSEvent* event in someEvents) { plist::dictionary_t::const_iterator pair = KeyEventContext->find(to_s(event)); if(pair == KeyEventContext->end() && KeyEventContext != &KeyBindings) { KeyEventContext = &KeyBindings; pair = KeyEventContext->find(to_s(event)); } if(pair == KeyEventContext->end()) [super interpretKeyEvents:@[ event ]]; else [self handleKeyBindingAction:pair->second]; } } - (void)oldKeyDown:(NSEvent*)anEvent { std::vector const& items = bundles::query(bundles::kFieldKeyEquivalent, to_s(anEvent), [self scopeContext]); if(bundles::item_ptr item = OakShowMenuForBundleItems(items, [self positionForWindowUnderCaret])) [self performBundleItem:item]; else if(items.empty()) [self interpretKeyEvents:@[ anEvent ]]; [NSCursor setHiddenUntilMouseMoves:YES]; [[NSNotificationCenter defaultCenter] postNotificationName:OakCursorDidHideNotification object:nil]; } - (void)keyDown:(NSEvent*)anEvent { D(DBF_OakTextView_TextInput, bug("%s\n", [[anEvent description] UTF8String]);); crash_reporter_info_t info(text::format("%s %s", sel_getName(_cmd), to_s(anEvent).c_str())); try { [self realKeyDown:anEvent]; } catch(std::exception const& e) { info << text::format("C++ Exception: %s", e.what()); abort(); } } - (void)realKeyDown:(NSEvent*)anEvent { AUTO_REFRESH; if(!choiceMenu) return [self oldKeyDown:anEvent]; ng::range_t oldSelection; std::string oldContent = editor->placeholder_content(&oldSelection); std::string oldPrefix = oldSelection ? oldContent.substr(0, oldSelection.min().index) : ""; NSUInteger event = [choiceMenu didHandleKeyEvent:anEvent]; if(event == OakChoiceMenuKeyUnused) { [self oldKeyDown:anEvent]; ng::range_t newSelection; std::string const& newContent = editor->placeholder_content(&newSelection); std::string const newPrefix = newSelection ? newContent.substr(0, newSelection.min().index) : ""; std::vector newChoices = editor->choices(); newChoices.erase(std::remove_if(newChoices.begin(), newChoices.end(), [&newPrefix](std::string const& str) { return str.find(newPrefix) != 0; }), newChoices.end()); choiceMenu.choices = (__bridge NSArray*)((CFArrayRef)cf::wrap(newChoices)); bool didEdit = oldPrefix != newPrefix; bool didDelete = didEdit && oldPrefix.find(newPrefix) == 0; if(didEdit && !didDelete) { NSUInteger choiceIndex = NSNotFound; if(std::find(newChoices.begin(), newChoices.end(), oldContent) != newChoices.end() && oldContent.find(newContent) == 0) { choiceIndex = std::find(newChoices.begin(), newChoices.end(), oldContent) - newChoices.begin(); } else { for(size_t i = 0; i < newChoices.size(); ++i) { if(newChoices[i].find(newContent) != 0) continue; choiceIndex = i; break; } } choiceMenu.choiceIndex = choiceIndex; if(choiceIndex != NSNotFound && newContent != newChoices[choiceIndex]) editor->set_placeholder_content(newChoices[choiceIndex], newPrefix.size()); } else if(oldContent != newContent) { choiceMenu.choiceIndex = NSNotFound; } } else if(event == OakChoiceMenuKeyMovement) { std::string const choice = to_s(choiceMenu.selectedChoice); if(choice != NULL_STR && choice != oldContent) editor->set_placeholder_content(choice, choice.find(oldPrefix) == 0 ? oldPrefix.size() : 0); } else { self.choiceMenu = nil; if(event != OakChoiceMenuKeyCancel) { editor->perform(ng::kInsertTab, layout.get(), [self indentCorrections], to_s([self scopeAttributes])); choiceVector.clear(); } } } - (BOOL)hasSelection { return editor->has_selection(); } - (void)flagsChanged:(NSEvent*)anEvent { AUTO_REFRESH; NSInteger modifiers = [anEvent modifierFlags] & (NSAlternateKeyMask | NSControlKeyMask | NSCommandKeyMask); BOOL isHoldingOption = modifiers & NSAlternateKeyMask ? YES : NO; BOOL didPressOption = modifiers == NSAlternateKeyMask; BOOL didReleaseOption = modifiers == 0 && optionDownDate && [optionDownDate timeIntervalSinceNow] > -0.18; BOOL isSelectingWithMouse = ([NSEvent pressedMouseButtons] & 1) && editor->has_selection(); D(DBF_OakTextView_TextInput, bug("press option %s, release option %s, is selecting with mouse %s\n", BSTR(didPressOption), BSTR(didReleaseOption), BSTR(isSelectingWithMouse));); self.showColumnSelectionCursor = isHoldingOption; self.optionDownDate = nil; if(isSelectingWithMouse) { if(editor->ranges().last().columnar != isHoldingOption) [self toggleColumnSelection:self]; } // this checks if the ‘flags changed’ is caused by left/right option — the virtual key codes aren’t documented anywhere and in theory they could correspond to other keys, but worst case user lose the ability to toggle column selection by single-clicking option if([anEvent keyCode] != 58 && [anEvent keyCode] != 61) return; if(didPressOption) self.optionDownDate = [NSDate date]; else if(didReleaseOption) [self toggleColumnSelection:self]; } - (void)insertText:(id)aString { D(DBF_OakTextView_TextInput, bug("‘%s’, has marked %s\n", [[aString description] UTF8String], BSTR(!markedRanges.empty()));); AUTO_REFRESH; if(!markedRanges.empty()) { editor->set_selections(markedRanges); [self delete:nil]; markedRanges = ng::ranges_t(); } pendingMarkedRanges = ng::ranges_t(); if(![aString isKindOfClass:[NSString class]]) { if([aString respondsToSelector:@selector(string)]) aString = [aString string]; else if([aString respondsToSelector:@selector(description)]) aString = [aString description]; else aString = @""; } [self recordSelector:_cmd withArgument:[aString copy]]; bool autoPairing = !macroRecordingArray && ![[NSUserDefaults standardUserDefaults] boolForKey:kUserDefaultsDisableTypingPairsKey]; editor->insert_with_pairing([aString UTF8String], [self indentCorrections], autoPairing, to_s([self scopeAttributes])); } - (IBAction)toggleCurrentFolding:(id)sender { AUTO_REFRESH; if(editor->ranges().size() == 1 && !editor->ranges().last().empty() && !editor->ranges().last().columnar) { layout->fold(editor->ranges().last().min().index, editor->ranges().last().max().index); } else { size_t line = document->buffer().convert(editor->ranges().last().first.index).line; layout->toggle_fold_at_line(line, false); } [[NSNotificationCenter defaultCenter] postNotificationName:GVColumnDataSourceDidChange object:[[self enclosingScrollView] superview]]; } - (IBAction)toggleFoldingAtLine:(NSUInteger)lineNumber recursive:(BOOL)flag { AUTO_REFRESH; layout->toggle_fold_at_line(lineNumber, flag); } - (IBAction)takeLevelToFoldFrom:(id)sender { AUTO_REFRESH; layout->toggle_all_folds_at_level([sender tag]); [[NSNotificationCenter defaultCenter] postNotificationName:GVColumnDataSourceDidChange object:[[self enclosingScrollView] superview]]; } - (NSPoint)positionForWindowUnderCaret { CGRect r1 = layout->rect_at_index(editor->ranges().last().normalized().first); CGRect r2 = layout->rect_at_index(editor->ranges().last().normalized().last); CGRect r = r1.origin.y == r2.origin.y && r1.origin.x < r2.origin.x ? r1 : r2; NSPoint p = NSMakePoint(CGRectGetMinX(r), CGRectGetMaxY(r)+4); if(NSPointInRect(p, [self visibleRect])) { p = [[self window] convertRectToScreen:[self convertRect:(NSRect){ p, NSZeroSize } toView:nil]].origin; } else { p = [NSEvent mouseLocation]; p.y -= 16; } return p; } - (NSString*)selectAndReturnMisspelledWordAtIndex:(size_t)currnetIndex { AUTO_REFRESH; NSString* word = nil; ng::buffer_t const& buf = document->buffer(); if(!editor->has_selection()) { ng::range_t wordRange = ng::extend(buf, ng::index_t(currnetIndex), kSelectionExtendToWord).last(); if(ns::is_misspelled(buf.substr(wordRange.min().index, wordRange.max().index), buf.spelling_language(), buf.spelling_tag())) { editor->set_selections(wordRange); word = [NSString stringWithCxxString:buf.substr(wordRange.min().index, wordRange.max().index)]; } } else { ng::ranges_t ranges = editor->ranges(); if(ranges.size() == 1) { std::string const str = buf.substr(ranges.first().min().index, ranges.first().max().index); if(str.find_first_of(" \n\t") == std::string::npos && ns::is_misspelled(str, document->buffer().spelling_language(), document->buffer().spelling_tag())) word = [NSString stringWithCxxString:str]; } } return word; } - (NSMenu*)contextMenuWithMisspelledWord:(NSString*)aWord andOtherActions:(BOOL)otherActions { NSMenu* menu = [[NSMenu alloc] initWithTitle:@""]; NSMenuItem* item = nil; if(aWord) { char key = 0; [[NSSpellChecker sharedSpellChecker] updateSpellingPanelWithMisspelledWord:aWord]; for(NSString* guess in [[NSSpellChecker sharedSpellChecker] guessesForWord:aWord]) { item = [menu addItemWithTitle:guess action:@selector(contextMenuPerformCorrectWord:) keyEquivalent:key < 10 ? [NSString stringWithFormat:@"%c", '0' + (++key % 10)] : @""]; [item setKeyEquivalentModifierMask:0]; [item setRepresentedObject:guess]; } if([menu numberOfItems] == 0) [menu addItemWithTitle:@"No Guesses Found" action:nil keyEquivalent:@""]; [menu addItem:[NSMenuItem separatorItem]]; item = [menu addItemWithTitle:@"Ignore Spelling" action:@selector(contextMenuPerformIgnoreSpelling:) keyEquivalent:@"-"]; [item setKeyEquivalentModifierMask:0]; [item setRepresentedObject:aWord]; item = [menu addItemWithTitle:@"Learn Spelling" action:@selector(contextMenuPerformLearnSpelling:) keyEquivalent:@"="]; [item setKeyEquivalentModifierMask:0]; [item setRepresentedObject:aWord]; [menu addItem:[NSMenuItem separatorItem]]; if(!otherActions) { [menu addItemWithTitle:@"Find Next" action:@selector(checkSpelling:) keyEquivalent:@";"]; return menu; } } static struct { NSString* title; SEL action; } const items[] = { { @"Cut", @selector(cut:) }, { @"Copy", @selector(copy:) }, { @"Paste", @selector(paste:) }, { nil, nil }, { @"Fold/Unfold", @selector(toggleCurrentFolding:) }, { @"Filter Through Command…", @selector(orderFrontRunCommandWindow:) }, }; for(size_t i = 0; i < sizeofA(items); i++) { if(items[i].title) [menu addItemWithTitle:items[i].title action:items[i].action keyEquivalent:@""]; else [menu addItem:[NSMenuItem separatorItem]]; } return menu; } - (NSMenu*)menuForEvent:(NSEvent*)anEvent { NSPoint point = [self convertPoint:[anEvent locationInWindow] fromView:nil]; ng::index_t const& click = layout->index_at_point(point); return [self contextMenuWithMisspelledWord:[self selectAndReturnMisspelledWordAtIndex:click.index] andOtherActions:YES]; } - (void)showMenu:(NSMenu*)aMenu { NSWindow* win = [self window]; NSEvent* anEvent = [NSApp currentEvent]; NSEvent* fakeEvent = [NSEvent mouseEventWithType:NSLeftMouseDown location:[win convertRectFromScreen:(NSRect){ [self positionForWindowUnderCaret], NSZeroSize }].origin modifierFlags:0 timestamp:[anEvent timestamp] windowNumber:[win windowNumber] context:[anEvent context] eventNumber:0 clickCount:1 pressure:1]; [NSMenu popUpContextMenu:aMenu withEvent:fakeEvent forView:self]; [win performSelector:@selector(invalidateCursorRectsForView:) withObject:self afterDelay:0]; // with option used as modifier, the cross-hair cursor will stick } - (void)showContextMenu:(id)sender { NSString* word = [self selectAndReturnMisspelledWordAtIndex:editor->ranges().last().last.index]; [self showMenu:[self contextMenuWithMisspelledWord:word andOtherActions:YES]]; } - (void)contextMenuPerformCorrectWord:(NSMenuItem*)menuItem { D(DBF_OakTextView_Spelling, bug("%s\n", [[menuItem representedObject] UTF8String]);); AUTO_REFRESH; editor->insert(to_s((NSString*)[menuItem representedObject])); if([NSSpellChecker sharedSpellCheckerExists]) [[NSSpellChecker sharedSpellChecker] updateSpellingPanelWithMisspelledWord:[menuItem representedObject]]; } - (void)contextMenuPerformIgnoreSpelling:(id)sender { D(DBF_OakTextView_Spelling, bug("%s\n", [[sender representedObject] UTF8String]);); [self ignoreSpelling:[sender representedObject]]; } - (void)contextMenuPerformLearnSpelling:(id)sender { D(DBF_OakTextView_Spelling, bug("%s\n", [[sender representedObject] UTF8String]);); [[NSSpellChecker sharedSpellChecker] learnWord:[sender representedObject]]; document->buffer().recheck_spelling(0, document->buffer().size()); [self setNeedsDisplay:YES]; } - (void)ignoreSpelling:(id)sender { NSString* word = nil; if([sender respondsToSelector:@selector(selectedCell)]) word = [[sender selectedCell] stringValue]; else if([sender isKindOfClass:[NSString class]]) word = sender; D(DBF_OakTextView_Spelling, bug("%s → %s\n", [[sender description] UTF8String], [word UTF8String]);); if(word) { [[NSSpellChecker sharedSpellChecker] ignoreWord:word inSpellDocumentWithTag:document->buffer().spelling_tag()]; document->buffer().recheck_spelling(0, document->buffer().size()); [self setNeedsDisplay:YES]; } } - (void)changeSpelling:(id)sender { D(DBF_OakTextView_Spelling, bug("%s\n", [[sender description] UTF8String]);); if([sender respondsToSelector:@selector(selectedCell)]) { AUTO_REFRESH; editor->insert(to_s((NSString*)[[sender selectedCell] stringValue])); } } // ========================= // = Find Protocol: Client = // ========================= - (void)performFindOperation:(id )aFindServer { [[NSNotificationCenter defaultCenter] postNotificationName:@"OakTextViewWillPerformFindOperation" object:self]; if(![aFindServer isKindOfClass:[OakTextViewFindServer class]]) { NSMutableDictionary* dict = [NSMutableDictionary dictionary]; dict[@"findString"] = aFindServer.findString; dict[@"replaceString"] = aFindServer.replaceString; static find_operation_t const inSelectionActions[] = { kFindOperationFindInSelection, kFindOperationReplaceAllInSelection }; if(oak::contains(std::begin(inSelectionActions), std::end(inSelectionActions), aFindServer.findOperation)) dict[@"replaceAllScope"] = @"selection"; find::options_t options = aFindServer.findOptions; if(options & find::ignore_case) dict[@"ignoreCase"] = @YES; if(options & find::ignore_whitespace) dict[@"ignoreWhitespace"] = @YES; if(options & find::regular_expression) dict[@"regularExpression"] = @YES; if(options & find::wrap_around) dict[@"wrapAround"] = @YES; switch(aFindServer.findOperation) { case kFindOperationFind: case kFindOperationFindInSelection: { if(options & find::all_matches) dict[@"action"] = @"findAll"; else if(options & find::backwards) dict[@"action"] = @"findPrevious"; else dict[@"action"] = @"findNext"; } break; case kFindOperationReplaceAll: case kFindOperationReplaceAllInSelection: dict[@"action"] = @"replaceAll"; break; case kFindOperationReplace: dict[@"action"] = @"replace"; break; case kFindOperationReplaceAndFind: dict[@"action"] = @"replaceAndFind"; break; } if(dict[@"action"]) [self recordSelector:@selector(findWithOptions:) withArgument:dict]; } AUTO_REFRESH; find_operation_t findOperation = aFindServer.findOperation; if(findOperation == kFindOperationReplace || findOperation == kFindOperationReplaceAndFind) { std::string replacement = to_s(aFindServer.replaceString); if(NSDictionary* captures = self.matchCaptures) { std::map variables; for(NSString* key in [captures allKeys]) variables.emplace(to_s(key), to_s((NSString*)captures[key])); replacement = format_string::expand(replacement, variables); } editor->insert(replacement, true); if(findOperation == kFindOperationReplaceAndFind) findOperation = kFindOperationFind; } bool onlyInSelection = false; switch(findOperation) { case kFindOperationFindInSelection: case kFindOperationCountInSelection: onlyInSelection = editor->has_selection(); case kFindOperationFind: case kFindOperationCount: { self.matchCaptures = nil; bool isCounting = findOperation == kFindOperationCount || findOperation == kFindOperationCountInSelection; std::string const findStr = to_s(aFindServer.findString); find::options_t options = aFindServer.findOptions; NSArray* documents = [[OakPasteboard pasteboardWithName:NSFindPboard].auxiliaryOptionsForCurrent objectForKey:@"documents"]; if(documents && [documents count] > 1) options &= ~find::wrap_around; bool didWrap = false; auto allMatches = ng::find(document->buffer(), editor->ranges(), findStr, options, onlyInSelection ? editor->ranges() : ng::ranges_t(), &didWrap); ng::ranges_t res; std::transform(allMatches.begin(), allMatches.end(), std::back_inserter(res), [](auto const& p){ return p.first; }); if(onlyInSelection && res.sorted() == editor->ranges().sorted()) { res = ng::ranges_t(); allMatches = ng::find(document->buffer(), editor->ranges(), findStr, options, ng::ranges_t()); std::transform(allMatches.begin(), allMatches.end(), std::back_inserter(res), [](auto const& p){ return p.first; }); } if(res.empty() && !isCounting && documents && [documents count] > 1) { for(NSUInteger i = 0; i < [documents count]; ++i) { NSString* uuid = [[documents objectAtIndex:i] objectForKey:@"identifier"]; if(uuid && oak::uuid_t(to_s(uuid)) == document->identifier()) { NSDictionary* info = [documents objectAtIndex:(i + ((options & find::backwards) ? [documents count] - 1 : 1)) % [documents count]]; document::document_ptr doc; if(NSString* path = [info objectForKey:@"path"]) doc = document::create(to_s(path)); else if(NSString* identifier = [info objectForKey:@"identifier"]) doc = document::find(to_s(identifier)); if(doc) { NSString* range = [info objectForKey:(options & find::backwards) ? @"lastMatchRange" : @"firstMatchRange"]; document::show(doc, document::kCollectionAny, to_s(range)); return; } } } } if(isCounting) { [aFindServer didFind:res.size() occurrencesOf:aFindServer.findString atPosition:res.size() == 1 ? document->buffer().convert(res.last().min().index) : text::pos_t::undefined wrapped:NO]; } else { std::set alreadySelected; for(auto const& range : editor->ranges()) alreadySelected.insert(range); ng::ranges_t newSelection; for(auto range : res) { if(alreadySelected.find(range.sorted()) == alreadySelected.end()) newSelection.push_back(range.sorted()); } if(!res.empty()) { editor->set_selections(res); if(res.size() == 1 && (options & find::regular_expression)) { NSMutableDictionary* captures = [NSMutableDictionary dictionary]; for(auto pair : allMatches[res.last()]) captures[[NSString stringWithCxxString:pair.first]] = [NSString stringWithCxxString:pair.second]; self.matchCaptures = captures; } } [self highlightRanges:newSelection]; [aFindServer didFind:newSelection.size() occurrencesOf:aFindServer.findString atPosition:res.size() == 1 ? document->buffer().convert(res.last().min().index) : text::pos_t::undefined wrapped:didWrap]; } } break; case kFindOperationReplaceAll: case kFindOperationReplaceAllInSelection: { std::string const findStr = to_s(aFindServer.findString); std::string const replaceStr = to_s(aFindServer.replaceString); find::options_t options = aFindServer.findOptions; ng::ranges_t const res = editor->replace_all(findStr, replaceStr, options, findOperation == kFindOperationReplaceAllInSelection); [aFindServer didReplace:res.size() occurrencesOf:aFindServer.findString with:aFindServer.replaceString]; } break; } } - (void)recordSelector:(SEL)aSelector andPerform:(find_operation_t)findOperation withOptions:(find::options_t)extraOptions { [self recordSelector:aSelector withArgument:nil]; [self performFindOperation:[OakTextViewFindServer findServerWithTextView:self operation:findOperation options:[[OakPasteboard pasteboardWithName:NSFindPboard] current].findOptions | extraOptions]]; } - (void)setShowLiveSearch:(BOOL)flag { OakDocumentView* documentView = (OakDocumentView*)[[self enclosingScrollView] superview]; if(flag) { liveSearchAnchor = editor->ranges(); if(!self.liveSearchView) { self.liveSearchView = [[LiveSearchView alloc] initWithFrame:NSZeroRect]; [documentView addAuxiliaryView:self.liveSearchView atEdge:NSMinYEdge]; self.liveSearchView.nextResponder = self; } NSTextField* textField = self.liveSearchView.textField; [textField setDelegate:self]; [textField setStringValue:self.liveSearchString ?: @""]; [[self window] makeFirstResponder:textField]; } else if(self.liveSearchView) { [documentView removeAuxiliaryView:self.liveSearchView]; [[self window] makeFirstResponder:self]; self.liveSearchView = nil; liveSearchRanges = ng::ranges_t(); } } - (void)setLiveSearchRanges:(ng::ranges_t const&)ranges { AUTO_REFRESH; ng::ranges_t const oldRanges = ng::move(document->buffer(), liveSearchRanges, kSelectionMoveToBeginOfSelection); liveSearchRanges = ranges; if(!liveSearchRanges.empty()) { editor->set_selections(liveSearchRanges); if(oldRanges != ng::move(document->buffer(), liveSearchRanges, kSelectionMoveToBeginOfSelection)) [self highlightRanges:liveSearchRanges]; } else if(!oldRanges.empty()) { NSBeep(); } } - (BOOL)control:(NSControl*)aControl textView:(NSTextView*)aTextView doCommandBySelector:(SEL)aCommand { if(aCommand == @selector(insertNewline:) || aCommand == @selector(cancelOperation:)) return [self setShowLiveSearch:NO], YES; if(aCommand == @selector(insertTab:)) return [self findNext:self], YES; if(aCommand == @selector(insertBacktab:)) return [self findPrevious:self], YES; return NO; } - (void)controlTextDidChange:(NSNotification*)aNotification { NSTextView* searchField = [[aNotification userInfo] objectForKey:@"NSFieldEditor"]; self.liveSearchString = [searchField string]; ng::ranges_t res; for(auto const& pair : ng::find(document->buffer(), liveSearchAnchor, to_s(liveSearchString), find::ignore_case|find::ignore_whitespace|find::wrap_around)) res.push_back(pair.first); [self setLiveSearchRanges:res]; } - (IBAction)incrementalSearch:(id)sender { if(self.liveSearchView) [self findNext:self]; else [self setShowLiveSearch:YES]; } - (IBAction)incrementalSearchPrevious:(id)sender { if(self.liveSearchView) [self findPrevious:self]; else [self setShowLiveSearch:YES]; } - (IBAction)findNext:(id)sender { if(self.liveSearchView) { ng::ranges_t tmp; for(auto const& pair : ng::find(document->buffer(), ng::move(document->buffer(), liveSearchRanges.empty() ? liveSearchAnchor : liveSearchRanges, kSelectionMoveToEndOfSelection), to_s(liveSearchString), find::ignore_case|find::ignore_whitespace)) tmp.push_back(pair.first); [self setLiveSearchRanges:tmp]; if(!tmp.empty()) liveSearchAnchor = ng::move(document->buffer(), tmp, kSelectionMoveToBeginOfSelection); } else { [self recordSelector:_cmd andPerform:kFindOperationFind withOptions:find::none]; } } - (IBAction)findPrevious:(id)sender { if(self.liveSearchView) { ng::ranges_t tmp; for(auto const& pair : ng::find(document->buffer(), ng::move(document->buffer(), liveSearchRanges.empty() ? liveSearchAnchor : liveSearchRanges, kSelectionMoveToBeginOfSelection), to_s(liveSearchString), find::backwards|find::ignore_case|find::ignore_whitespace)) tmp.push_back(pair.first); [self setLiveSearchRanges:tmp]; if(!tmp.empty()) liveSearchAnchor = ng::move(document->buffer(), tmp, kSelectionMoveToBeginOfSelection); } else { [self recordSelector:_cmd andPerform:kFindOperationFind withOptions:find::backwards]; } } - (IBAction)findNextAndModifySelection:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationFind withOptions:find::extend_selection]; } - (IBAction)findPreviousAndModifySelection:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationFind withOptions:find::extend_selection | find::backwards]; } - (IBAction)findAll:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationFind withOptions:find::all_matches]; } - (IBAction)findAllInSelection:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationFindInSelection withOptions:find::all_matches]; } - (IBAction)replace:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationReplace withOptions:find::none]; } - (IBAction)replaceAndFind:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationReplaceAndFind withOptions:find::none]; } - (IBAction)replaceAll:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationReplaceAll withOptions:find::all_matches]; } - (IBAction)replaceAllInSelection:(id)sender { [self recordSelector:_cmd andPerform:kFindOperationReplaceAllInSelection withOptions:find::all_matches]; } - (void)insertSnippetWithOptions:(NSDictionary*)someOptions // For Dialog popup { AUTO_REFRESH; [self recordSelector:_cmd withArgument:someOptions]; editor->snippet_dispatch(plist::convert((__bridge CFDictionaryRef)someOptions), [self variables]); } - (void)undo:(id)anArgument // MACRO? { AUTO_REFRESH; if(!document->undo_manager().can_undo()) return; editor->clear_snippets(); editor->set_selections(document->undo_manager().undo()); } - (void)redo:(id)anArgument // MACRO? { AUTO_REFRESH; if(!document->undo_manager().can_redo()) return; editor->clear_snippets(); editor->set_selections(document->undo_manager().redo()); } - (BOOL)expandTabTrigger:(id)sender { if(editor->disallow_tab_expansion()) return NO; AUTO_REFRESH; ng::range_t range; std::vector const& items = items_for_tab_expansion(document->buffer(), editor->ranges(), to_s([self scopeAttributes]), &range); if(bundles::item_ptr item = OakShowMenuForBundleItems(items, [self positionForWindowUnderCaret])) { [self recordSelector:@selector(deleteTabTrigger:) withArgument:[NSString stringWithCxxString:editor->as_string(range.first.index, range.last.index)]]; editor->delete_tab_trigger(editor->as_string(range.first.index, range.last.index)); [self performBundleItem:item]; } return !items.empty(); } - (void)insertTab:(id)sender { AUTO_REFRESH; if(![self expandTabTrigger:sender]) { [self recordSelector:_cmd withArgument:nil]; editor->perform(ng::kInsertTab, layout.get(), [self indentCorrections], to_s([self scopeAttributes])); } } static char const* kOakMenuItemTitle = "OakMenuItemTitle"; - (BOOL)validateMenuItem:(NSMenuItem*)aMenuItem { NSString* title = objc_getAssociatedObject(aMenuItem, kOakMenuItemTitle) ?: aMenuItem.title; objc_setAssociatedObject(aMenuItem, kOakMenuItemTitle, title, OBJC_ASSOCIATION_RETAIN); [aMenuItem updateTitle:[NSString stringWithCxxString:format_bundle_item_title(to_s(title), [self hasSelection])]]; if([aMenuItem action] == @selector(cut:)) [aMenuItem setTitle:@"Cut"]; else if([aMenuItem action] == @selector(copy:)) [aMenuItem setTitle:@"Copy"]; static auto const RequiresSelection = new std::set{ @selector(cut:), @selector(copy:), @selector(delete:), @selector(copySelectionToFindPboard:) }; if(RequiresSelection->find([aMenuItem action]) != RequiresSelection->end()) return [self hasSelection]; else if([aMenuItem action] == @selector(toggleMacroRecording:)) [aMenuItem setTitle:self.isMacroRecording ? @"Stop Recording" : @"Start Recording"]; else if([aMenuItem action] == @selector(toggleShowInvisibles:)) [aMenuItem setTitle:self.showInvisibles ? @"Hide Invisible Characters" : @"Show Invisible Characters"]; else if([aMenuItem action] == @selector(toggleSoftWrap:)) [aMenuItem setTitle:self.softWrap ? @"Disable Soft Wrap" : @"Enable Soft Wrap"]; else if([aMenuItem action] == @selector(toggleScrollPastEnd:)) [aMenuItem setTitle:self.scrollPastEnd ? @"Disallow Scroll Past End" : @"Allow Scroll Past End"]; else if([aMenuItem action] == @selector(toggleShowWrapColumn:)) [aMenuItem setTitle:(layout && layout->draw_wrap_column()) ? @"Hide Wrap Column" : @"Show Wrap Column"]; else if([aMenuItem action] == @selector(toggleContinuousSpellChecking:)) [aMenuItem setState:document->buffer().live_spelling() ? NSOnState : NSOffState]; else if([aMenuItem action] == @selector(takeSpellingLanguageFrom:)) [aMenuItem setState:[[NSString stringWithCxxString:document->buffer().spelling_language()] isEqualToString:[aMenuItem representedObject]] ? NSOnState : NSOffState]; else if([aMenuItem action] == @selector(takeWrapColumnFrom:)) [aMenuItem setState:wrapColumn == [aMenuItem tag] ? NSOnState : NSOffState]; else if([aMenuItem action] == @selector(undo:)) { [aMenuItem setTitle:@"Undo"]; return document->undo_manager().can_undo(); } else if([aMenuItem action] == @selector(redo:)) { [aMenuItem setTitle:@"Redo"]; return document->undo_manager().can_redo(); } return YES; } // ================== // = Caret Blinking = // ================== - (NSTimer*)blinkCaretTimer { return blinkCaretTimer; } - (void)setBlinkCaretTimer:(NSTimer*)aValue { [blinkCaretTimer invalidate]; blinkCaretTimer = aValue; } - (void)resetBlinkCaretTimer { BOOL hasFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); if(hasFocus && layout) { AUTO_REFRESH; layout->set_draw_caret(true); hideCaret = NO; self.blinkCaretTimer = [NSTimer scheduledTimerWithTimeInterval:[NSEvent caretBlinkInterval] target:self selector:@selector(toggleCaretVisibility:) userInfo:nil repeats:YES]; } } - (void)toggleCaretVisibility:(id)sender { if(!layout) return; AUTO_REFRESH; layout->set_draw_caret(hideCaret); hideCaret = !hideCaret; // The column selection cursor may get stuck if e.g. using ⌥F2 to bring up a menu: We see the initial “option down” but newer the “option release” that would normally reset the column selection cursor state. if(([NSEvent modifierFlags] & NSAlternateKeyMask) == 0) self.showColumnSelectionCursor = NO; } - (void)setShowColumnSelectionCursor:(BOOL)flag { D(DBF_OakTextView_TextInput, bug("%s → %s\n", BSTR(showColumnSelectionCursor), BSTR(flag));); if(flag != showColumnSelectionCursor) { showColumnSelectionCursor = flag; [[self window] invalidateCursorRectsForView:self]; } } // ============== // = Public API = // ============== - (theme_ptr const&)theme { return theme; } - (NSFont*)font { return [NSFont fontWithName:[NSString stringWithCxxString:fontName] size:fontSize]; } - (size_t)tabSize { return document ? document->indent().tab_size() : 2; } - (BOOL)softTabs { return document ? document->indent().soft_tabs() : NO; } - (BOOL)softWrap { return layout && layout->wrapping(); } - (ng::indent_correction_t)indentCorrections { plist::any_t indentCorrections = bundles::value_for_setting("disableIndentCorrections", [self scopeContext]); if(std::string const* str = boost::get(&indentCorrections)) { if(*str == "emptyLines") return ng::kIndentCorrectNonEmptyLines; } if(plist::is_true(indentCorrections)) return ng::kIndentCorrectNever; return ng::kIndentCorrectAlways; } - (void)setTheme:(theme_ptr const&)newTheme { theme = newTheme; if(layout) { AUTO_REFRESH; layout->set_theme(newTheme); } } - (void)setFont:(NSFont*)newFont { fontName = to_s([newFont fontName]); fontSize = [newFont pointSize]; if(layout) { AUTO_REFRESH; ng::index_t visibleIndex = layout->index_at_point([self visibleRect].origin); layout->set_font(fontName, fontSize); [self scrollIndexToFirstVisible:document->buffer().begin(document->buffer().convert(visibleIndex.index).line)]; } } - (void)setTabSize:(size_t)newTabSize { AUTO_REFRESH; if(document) { text::indent_t tmp = document->indent(); tmp.set_tab_size(newTabSize); document->set_indent(tmp); } } - (void)setShowInvisibles:(BOOL)flag { if(_showInvisibles == flag) return; _showInvisibles = flag; settings_t::set(kSettingsShowInvisiblesKey, (bool)flag, document->file_type()); [self setNeedsDisplay:YES]; } - (void)setScrollPastEnd:(BOOL)flag { if(_scrollPastEnd == flag) return; _scrollPastEnd = flag; [[NSUserDefaults standardUserDefaults] setBool:flag forKey:kUserDefaultsScrollPastEndKey]; if(layout) { AUTO_REFRESH; layout->set_scroll_past_end(flag); } } - (void)setSoftWrap:(BOOL)flag { if(!layout || layout->wrapping() == flag) return; AUTO_REFRESH; ng::index_t visibleIndex = layout->index_at_point([self visibleRect].origin); layout->set_wrapping(flag, wrapColumn); [self scrollIndexToFirstVisible:document->buffer().begin(document->buffer().convert(visibleIndex.index).line)]; settings_t::set(kSettingsSoftWrapKey, (bool)flag, document->file_type()); } - (void)setSoftTabs:(BOOL)flag { if(flag != self.softTabs) { text::indent_t tmp = document->indent(); tmp.set_soft_tabs(flag); document->set_indent(tmp); } } - (void)setWrapColumn:(NSInteger)newWrapColumn { if(wrapColumn == newWrapColumn) return; wrapColumn = newWrapColumn; settings_t::set(kSettingsWrapColumnKey, wrapColumn); if(wrapColumn != NSWrapColumnWindowWidth) { NSInteger const kWrapColumnPresetsHistorySize = 5; NSMutableArray* presets = [[[NSUserDefaults standardUserDefaults] arrayForKey:kUserDefaultsWrapColumnPresetsKey] mutableCopy]; [presets removeObject:@(wrapColumn)]; [presets addObject:@(wrapColumn)]; if(presets.count > kWrapColumnPresetsHistorySize) [presets removeObjectsInRange:NSMakeRange(0, presets.count - kWrapColumnPresetsHistorySize)]; [[NSUserDefaults standardUserDefaults] setObject:presets forKey:kUserDefaultsWrapColumnPresetsKey]; } if(layout) { AUTO_REFRESH; layout->set_wrapping(self.softWrap, wrapColumn); } } - (void)takeWrapColumnFrom:(id)sender { ASSERT([sender respondsToSelector:@selector(tag)]); if(wrapColumn == [sender tag]) return; if([sender tag] == NSWrapColumnAskUser) { NSTextField* textField = [[NSTextField alloc] initWithFrame:NSZeroRect]; [textField setIntegerValue:wrapColumn == NSWrapColumnWindowWidth ? 80 : wrapColumn]; [textField sizeToFit]; [textField setFrameSize:NSMakeSize(200, NSHeight([textField frame]))]; NSAlert* alert = [NSAlert alertWithMessageText:@"Set Wrap Column" defaultButton:@"OK" alternateButton:@"Cancel" otherButton:nil informativeTextWithFormat:@"Specify what column text should wrap at:"]; [alert setAccessoryView:textField]; OakShowAlertForWindow(alert, [self window], ^(NSInteger returnCode){ if(returnCode == NSAlertDefaultReturn) [self setWrapColumn:std::max([textField integerValue], 10)]; }); } else { [self setWrapColumn:[sender tag]]; } } - (BOOL)hasMultiLineSelection { return multiline(document->buffer(), editor->ranges()); } - (IBAction)toggleShowInvisibles:(id)sender { self.showInvisibles = !self.showInvisibles; } - (IBAction)toggleScrollPastEnd:(id)sender { self.scrollPastEnd = !self.scrollPastEnd; } - (IBAction)toggleSoftWrap:(id)sender { self.softWrap = !self.softWrap; } - (IBAction)toggleShowWrapColumn:(id)sender { if(layout) { AUTO_REFRESH; bool flag = !layout->draw_wrap_column(); layout->set_draw_wrap_column(flag); settings_t::set(kSettingsShowWrapColumnKey, flag); } } - (void)checkSpelling:(id)sender { NSSpellChecker* speller = [NSSpellChecker sharedSpellChecker]; ng::buffer_t& buf = document->buffer(); NSString* lang = [NSString stringWithCxxString:buf.spelling_language()]; if([[speller spellingPanel] isVisible]) { if(![[speller language] isEqualToString:lang]) { buf.set_spelling_language(to_s([speller language])); [self setNeedsDisplay:YES]; } } else { [speller setLanguage:lang]; } if(!buf.live_spelling()) { buf.set_live_spelling(true); [self setNeedsDisplay:YES]; } auto nextMisspelling = buf.next_misspelling(editor->ranges().last().last.index); if(nextMisspelling.first != nextMisspelling.second) { { AUTO_REFRESH; editor->set_selections(ng::range_t(nextMisspelling.first, nextMisspelling.second)); } NSString* word = [NSString stringWithCxxString:buf.substr(nextMisspelling.first, nextMisspelling.second)]; [speller updateSpellingPanelWithMisspelledWord:word]; if(![[speller spellingPanel] isVisible]) [self showMenu:[self contextMenuWithMisspelledWord:word andOtherActions:NO]]; } else { [speller updateSpellingPanelWithMisspelledWord:@""]; } } - (void)toggleContinuousSpellChecking:(id)sender { bool flag = !document->buffer().live_spelling(); document->buffer().set_live_spelling(flag); settings_t::set(kSettingsSpellCheckingKey, flag, document->file_type(), document->path()); [self setNeedsDisplay:YES]; } - (void)takeSpellingLanguageFrom:(id)sender { NSString* lang = (NSString*)[sender representedObject]; [[NSSpellChecker sharedSpellChecker] setLanguage:lang]; document->buffer().set_spelling_language(to_s(lang)); settings_t::set(kSettingsSpellingLanguageKey, to_s(lang), "", document->path()); if(document->path() != NULL_STR) settings_t::set(kSettingsSpellingLanguageKey, to_s(lang), NULL_STR, path::join(path::parent(document->path()), "**")); [self setNeedsDisplay:YES]; } - (scope::context_t)scopeContext { return editor->scope(to_s([self scopeAttributes])); } - (NSString*)scopeAsString // Used by https://github.com/emmetio/Emmet.tmplugin { return [NSString stringWithCxxString:to_s([self scopeContext].right)]; } - (void)setSelectionString:(NSString*)aSelectionString { if([aSelectionString isEqualToString:selectionString]) return; selectionString = [aSelectionString copy]; NSAccessibilityPostNotification(self, NSAccessibilitySelectedTextChangedNotification); if(UAZoomEnabled()) [self performSelector:@selector(updateZoom:) withObject:self afterDelay:0]; if(isUpdatingSelection) return; AUTO_REFRESH; ng::ranges_t ranges = convert(document->buffer(), to_s(aSelectionString)); editor->set_selections(ranges); for(auto const& range : ranges) layout->remove_enclosing_folds(range.min().index, range.max().index); } - (NSString*)selectionString { return selectionString; } - (void)updateSelection { text::selection_t ranges, withoutCarry; for(auto const& range : editor->ranges()) { text::pos_t from = document->buffer().convert(range.first.index); text::pos_t to = document->buffer().convert(range.last.index); if(!range.freehanded && !range.columnar) withoutCarry.push_back(text::range_t(from, to, range.columnar)); from.offset = range.first.carry; to.offset = range.last.carry; if(range.freehanded || range.columnar) withoutCarry.push_back(text::range_t(from, to, range.columnar)); ranges.push_back(text::range_t(from, to, range.columnar)); } document->set_selection(ranges); isUpdatingSelection = YES; [self setSelectionString:[NSString stringWithCxxString:withoutCarry]]; isUpdatingSelection = NO; } - (folding_state_t)foldingStateForLine:(NSUInteger)lineNumber { if(document) { if(layout->is_line_folded(lineNumber)) return kFoldingCollapsed; else if(layout->is_line_fold_start_marker(lineNumber)) return kFoldingTop; else if(layout->is_line_fold_stop_marker(lineNumber)) return kFoldingBottom; } return kFoldingNone; } - (GVLineRecord)lineRecordForPosition:(CGFloat)yPos { if(!layout) return GVLineRecord(); auto record = layout->line_record_for(yPos); return GVLineRecord(record.line, record.softline, record.top, record.bottom, record.baseline); } - (GVLineRecord)lineFragmentForLine:(NSUInteger)aLine column:(NSUInteger)aColumn { if(!layout) return GVLineRecord(); auto record = layout->line_record_for(text::pos_t(aLine, aColumn)); return GVLineRecord(record.line, record.softline, record.top, record.bottom, record.baseline); } - (BOOL)filterDocumentThroughCommand:(NSString*)commandString input:(input::type)inputUnit output:(output::type)outputUnit { BOOL res = NO; auto environment = [self variables]; if(io::process_t process = io::spawn(std::vector{ "/bin/sh", "-c", to_s(commandString) }, environment)) { bool inputWasSelection = false; ng::range_t inputRange = ng::write_unit_to_fd(document->buffer(), editor->ranges().last(), document->buffer().indent().tab_size(), process.in, inputUnit, input::entire_document, input_format::text, scope::selector_t(), environment, &inputWasSelection); __block int status = 0; __block std::string output, error; dispatch_group_t group = dispatch_group_create(); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ if(waitpid(process.pid, &status, 0) != process.pid) perror("waitpid"); }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ io::exhaust_fd(process.out, &output); }); dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ io::exhaust_fd(process.err, &error); }); dispatch_group_wait(group, DISPATCH_TIME_FOREVER); dispatch_release(group); if(res = WIFEXITED(status) && WEXITSTATUS(status) == 0) { if(outputUnit == output::tool_tip) { OakShowToolTip([NSString stringWithCxxString:text::trim(output)], [self positionForWindowUnderCaret]); } else if(outputUnit == output::new_window) { oak::uuid_t projectIdentifier = document::kCollectionAny; if([self.window.delegate respondsToSelector:@selector(identifier)]) // FIXME This should be a formal interface projectIdentifier = to_s((NSString*)[self.window.delegate performSelector:@selector(identifier)]); document::show(document::from_content(output, document->file_type()), projectIdentifier); } else { AUTO_REFRESH; editor->handle_result(output, outputUnit, output_format::text, output_caret::after_output, inputRange, environment); } } error = text::trim(error); if(error.empty() && !res) { if(WIFEXITED(status)) error = text::format("Failed executing ‘%s’.\nCommand returned non-zero status code: %d.", [commandString UTF8String], WEXITSTATUS(status)); else error = text::format("Failed executing ‘%s’.\nAbnormal exit: %d.", [commandString UTF8String], status); } if(!error.empty()) OakShowToolTip([NSString stringWithCxxString:error], [self positionForWindowUnderCaret]); } return res; } // =================== // = Macro Recording = // =================== - (BOOL)isMacroRecording { return macroRecordingArray != nil; } - (IBAction)toggleMacroRecording:(id)sender { self.isMacroRecording = !self.isMacroRecording; } - (void)setIsMacroRecording:(BOOL)flag { if(self.isMacroRecording == flag) return; D(DBF_OakTextView_Macros, bug("%s\n", BSTR(flag));); if(macroRecordingArray) { D(DBF_OakTextView_Macros, bug("%s\n", to_s(plist::convert((__bridge CFDictionaryRef)macroRecordingArray)).c_str());); [[NSUserDefaults standardUserDefaults] setObject:[macroRecordingArray copy] forKey:@"OakMacroManagerScratchMacro"]; macroRecordingArray = nil; } else { macroRecordingArray = [NSMutableArray new]; } } - (IBAction)playScratchMacro:(id)anArgument { D(DBF_OakTextView_Macros, bug("%s\n", to_s(plist::convert((__bridge CFDictionaryRef)[[NSUserDefaults standardUserDefaults] arrayForKey:@"OakMacroManagerScratchMacro"])).c_str());); AUTO_REFRESH; if(NSArray* scratchMacro = [[NSUserDefaults standardUserDefaults] arrayForKey:@"OakMacroManagerScratchMacro"]) editor->macro_dispatch(plist::convert((__bridge CFDictionaryRef)@{ @"commands" : scratchMacro }), [self variables]); else NSBeep(); } - (IBAction)saveScratchMacro:(id)sender { if(NSArray* scratchMacro = [[NSUserDefaults standardUserDefaults] arrayForKey:@"OakMacroManagerScratchMacro"]) { bundles::item_ptr bundle; if([[BundlesManager sharedInstance] findBundleForInstall:&bundle]) { oak::uuid_t uuid = oak::uuid_t().generate(); plist::dictionary_t plist = plist::convert((__bridge CFDictionaryRef)@{ @"commands" : scratchMacro }); plist[bundles::kFieldUUID] = to_s(uuid); plist[bundles::kFieldName] = std::string("untitled"); auto item = std::make_shared(uuid, bundle, bundles::kItemTypeMacro); item->set_plist(plist); bundles::add_item(item); [NSApp sendAction:@selector(editBundleItemWithUUIDString:) to:nil from:[NSString stringWithCxxString:uuid]]; } } } - (void)recordSelector:(SEL)aSelector withArgument:(id)anArgument { if(!macroRecordingArray) return; D(DBF_OakTextView_Macros, bug("%s, %s\n", sel_getName(aSelector), [[anArgument description] UTF8String]);); [macroRecordingArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:NSStringFromSelector(aSelector), @"command", anArgument, @"argument", nil]]; } // ================ // = Drop Support = // ================ + (NSArray*)dropTypes { return @[ NSColorPboardType, NSFilenamesPboardType, @"WebURLsWithTitlesPboardType", (NSString*)kUTTypeURL, @"public.url-name", NSURLPboardType, @"public.plain-text" ]; } - (void)setDropMarkAtPoint:(NSPoint)aPoint { ASSERT(layout); AUTO_REFRESH; dropPosition = NSEqualPoints(aPoint, NSZeroPoint) ? ng::index_t() : layout->index_at_point(aPoint).index; layout->set_drop_marker(dropPosition); } - (void)dropFiles:(NSArray*)someFiles { D(DBF_OakTextView_DragNDrop, bug("%s\n", [[someFiles description] UTF8String]);); std::set allHandlers; std::map > handlerToFiles; scope::context_t scope = [self scopeContext]; for(NSString* path in someFiles) { for(auto const& item : bundles::drag_commands_for_path(to_s(path), scope)) { D(DBF_OakTextView_DragNDrop, bug("handler: %s\n", item->full_name().c_str());); handlerToFiles[item->uuid()].push_back(to_s(path)); allHandlers.insert(item); } } if(allHandlers.empty()) { bool binary = false; std::string merged = ""; for(NSString* path in someFiles) { D(DBF_OakTextView_DragNDrop, bug("insert as text: %s\n", [path UTF8String]);); std::string const& content = path::content(to_s(path)); if(!utf8::is_valid(content.begin(), content.end())) binary = true; else if(content.size() < SQ(1024) || NSAlertDefaultReturn == NSRunAlertPanel(@"Inserting Large File", @"The file “%@” has a size of %.1f MB. Are you sure you want to insert this as a text file?", @"Insert File", @"Cancel", nil, [path stringByAbbreviatingWithTildeInPath], content.size() / SQ(1024.0))) // larger than 1 MB? merged += content; } if(binary) { std::vector paths; for(NSString* path in someFiles) paths.push_back(to_s(path)); merged = text::join(paths, "\n"); } AUTO_REFRESH; editor->insert(merged, true); } else if(bundles::item_ptr handler = OakShowMenuForBundleItems(std::vector(allHandlers.begin(), allHandlers.end()), [self positionForWindowUnderCaret])) { D(DBF_OakTextView_DragNDrop, bug("execute %s\n", handler->full_name().c_str());); static struct { NSUInteger qual; std::string name; } const qualNames[] = { { NSShiftKeyMask, "SHIFT" }, { NSControlKeyMask, "CONTROL" }, { NSAlternateKeyMask, "OPTION" }, { NSCommandKeyMask, "COMMAND" } }; auto env = [self variablesForBundleItem:handler]; auto const pwd = format_string::expand("${TM_DIRECTORY:-${TM_PROJECT_DIRECTORY:-$TMPDIR}}", env); std::vector files, paths = handlerToFiles[handler->uuid()]; std::transform(paths.begin(), paths.end(), back_inserter(files), [&pwd](std::string const& path){ return path::relative_to(path, pwd); }); env["TM_DROPPED_FILE"] = files.front(); env["TM_DROPPED_FILEPATH"] = paths.front(); if(files.size() > 1) { env["TM_DROPPED_FILES"] = shell_quote(files); env["TM_DROPPED_FILEPATHS"] = shell_quote(paths); } NSUInteger state = [NSEvent modifierFlags]; std::vector flagNames; for(size_t i = 0; i != sizeofA(qualNames); ++i) { if(state & qualNames[i].qual) flagNames.push_back(qualNames[i].name); } env["TM_MODIFIER_FLAGS"] = text::join(flagNames, "|"); AUTO_REFRESH; document::run(parse_drag_command(handler), document->buffer(), editor->ranges(), document, env, pwd); } } // =============== // = Drag Source = // =============== - (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal { return isLocal ? (NSDragOperationCopy|NSDragOperationMove) : (NSDragOperationCopy|NSDragOperationGeneric); } // ==================== // = Drag Destination = // ==================== - (BOOL)isPointInSelection:(NSPoint)aPoint { BOOL res = NO; for(auto const& rect : layout->rects_for_ranges(editor->ranges(), kRectsIncludeSelections)) res = res || CGRectContainsPoint(rect, aPoint); return res; } - (NSDragOperation)dragOperationForInfo:(id )info { if(macroRecordingArray || [self isHiddenOrHasHiddenAncestor]) return NSDragOperationNone; NSDragOperation mask = [info draggingSourceOperationMask]; NSDragOperation res; if([info draggingSource] == self) { BOOL hoveringSelection = [self isPointInSelection:[self convertPoint:[info draggingLocation] fromView:nil]]; res = hoveringSelection ? NSDragOperationNone : ((mask & NSDragOperationMove) ?: (mask & NSDragOperationCopy)); } else if([[info draggingPasteboard] availableTypeFromArray:@[ NSFilenamesPboardType ]]) { res = (mask & NSDragOperationCopy) ?: (mask & NSDragOperationLink); } else { res = (mask & NSDragOperationCopy); } return res; } - (NSDragOperation)draggingEntered:(id )info { D(DBF_OakTextView_DragNDrop, bug("hidden: %s\n", BSTR([self isHiddenOrHasHiddenAncestor]));); NSDragOperation flag = [self dragOperationForInfo:info]; [self setDropMarkAtPoint:flag == NSDragOperationNone ? NSZeroPoint : [self convertPoint:[info draggingLocation] fromView:nil]]; return flag; } - (NSDragOperation)draggingUpdated:(id )info { D(DBF_OakTextView_DragNDrop, bug("\n");); NSDragOperation flag = [self dragOperationForInfo:info]; [self setDropMarkAtPoint:flag == NSDragOperationNone ? NSZeroPoint : [self convertPoint:[info draggingLocation] fromView:nil]]; return flag; } - (void)draggingExited:(id )info { D(DBF_OakTextView_DragNDrop, bug("\n");); [self setDropMarkAtPoint:NSZeroPoint]; } - (BOOL)performDragOperation:(id )info { D(DBF_OakTextView_DragNDrop, bug("\n");); ASSERT(dropPosition); AUTO_REFRESH; ng::index_t pos = dropPosition; layout->set_drop_marker(dropPosition = ng::index_t()); BOOL res = YES; NSPasteboard* pboard = [info draggingPasteboard]; NSArray* types = [pboard types]; NSString* type DB_VAR = [pboard availableTypeFromArray:[[self class] dropTypes]]; BOOL shouldMove = ([info draggingSource] == self) && ([info draggingSourceOperationMask] & NSDragOperationMove); BOOL shouldLink = ([info draggingSource] != self) && ([info draggingSourceOperationMask] == NSDragOperationLink); D(DBF_OakTextView_DragNDrop, bug("local %s, should move %s, type %s, all types %s\n", BSTR([info draggingSource] == self), BSTR(shouldMove), [type UTF8String], [[types description] UTF8String]);); NSArray* files = [pboard availableTypeFromArray:@[ NSFilenamesPboardType ]] ? [pboard propertyListForType:NSFilenamesPboardType] : nil; if(shouldLink && files) { std::vector paths; for(NSString* path in files) paths.push_back(to_s(path)); editor->set_selections(ng::range_t(pos)); editor->insert(text::join(paths, "\n")); } else if(NSString* text = [pboard stringForType:[pboard availableTypeFromArray:@[ @"public.plain-text" ]]] ?: [pboard stringForType:NSStringPboardType]) { D(DBF_OakTextView_DragNDrop, bug("plain text: %s\n", [text UTF8String]);); if(shouldMove) { editor->move_selection_to(pos); } else { std::string str = to_s(text); str.erase(text::convert_line_endings(str.begin(), str.end(), text::estimate_line_endings(str.begin(), str.end())), str.end()); str.erase(utf8::remove_malformed(str.begin(), str.end()), str.end()); editor->set_selections(ng::range_t(pos)); editor->insert(str); } } else if(files) { editor->set_selections(ng::range_t(pos)); [self performSelector:@selector(dropFiles:) withObject:files afterDelay:0.05]; // we use “afterDelay” so that slow commands won’t trigger a timeout of the drop event } else { fprintf(stderr, "unknown drop: %s\n", [[types description] UTF8String]); res = NO; } return res; } // ================== // = Cursor Support = // ================== - (void)setIbeamCursor:(NSCursor*)aCursor { if(_ibeamCursor != aCursor) { _ibeamCursor = aCursor; [[self window] invalidateCursorRectsForView:self]; } } - (void)resetCursorRects { D(DBF_OakTextView_MouseEvents, bug("drag: %s, column selection: %s\n", BSTR(showDragCursor), BSTR(showColumnSelectionCursor));); [self addCursorRect:[self visibleRect] cursor:showDragCursor ? [NSCursor arrowCursor] : (showColumnSelectionCursor ? [NSCursor crosshairCursor] : [self ibeamCursor])]; } - (void)setShowDragCursor:(BOOL)flag { if(flag != showDragCursor) { showDragCursor = flag; [[self window] invalidateCursorRectsForView:self]; } } // ================= // = User Defaults = // ================= - (void)userDefaultsDidChange:(id)sender { self.antiAlias = ![[NSUserDefaults standardUserDefaults] boolForKey:kUserDefaultsDisableAntiAliasKey]; self.fontSmoothing = (OTVFontSmoothing)[[NSUserDefaults standardUserDefaults] integerForKey:kUserDefaultsFontSmoothingKey]; self.scrollPastEnd = [[NSUserDefaults standardUserDefaults] boolForKey:kUserDefaultsScrollPastEndKey]; } // ================= // = Mouse Support = // ================= - (void)actOnMouseDown { bool optionDown = mouseDownModifierFlags & NSAlternateKeyMask; bool shiftDown = mouseDownModifierFlags & NSShiftKeyMask; bool commandDown = mouseDownModifierFlags & NSCommandKeyMask; ng::ranges_t s = editor->ranges(); ng::index_t index = layout->index_at_point(mouseDownPos); if(!optionDown) index.carry = 0; ng::index_t min = s.last().min(), max = s.last().max(); mouseDownIndex = shiftDown ? (index <= min ? max : (max <= index ? min : s.last().first)) : index; ng::ranges_t range(ng::range_t(mouseDownIndex, index)); switch(mouseDownClickCount) { case 2: range = ng::extend(document->buffer(), range, kSelectionExtendToWord); break; case 3: range = ng::extend(document->buffer(), range, kSelectionExtendToLine); break; } if(optionDown) { if(shiftDown) range.last().columnar = true; else range.last().freehanded = true; } if(commandDown && mouseDownClickCount == 1) { bool didToggle = false; ng::ranges_t newSel; for(auto const& cur : s) { if(cur != range.last()) newSel.push_back(cur); else didToggle = true; } s = newSel; if(s.empty() || !didToggle) s.push_back(range.last()); } else if(shiftDown || (commandDown && mouseDownClickCount != 1)) s.last() = range.last(); else s = range.last(); editor->set_selections(s); } - (void)actOnMouseDragged:(NSEvent*)anEvent { NSPoint mouseCurrentPos = [self convertPoint:[anEvent locationInWindow] fromView:nil]; ng::ranges_t range(ng::range_t(mouseDownIndex, layout->index_at_point(mouseCurrentPos))); switch(mouseDownClickCount) { case 2: range = ng::extend(document->buffer(), range, kSelectionExtendToWord); break; case 3: range = ng::extend(document->buffer(), range, kSelectionExtendToLine); break; } NSUInteger currentModifierFlags = [anEvent modifierFlags]; if(currentModifierFlags & NSAlternateKeyMask) range.last().columnar = true; ng::ranges_t s = editor->ranges(); s.last() = range.last(); editor->set_selections(s); [self autoscroll:anEvent]; } - (void)startDragForEvent:(NSEvent*)anEvent { ASSERT(layout); NSRect srcRect; ng::ranges_t const ranges = ng::dissect_columnar(document->buffer(), editor->ranges()); NSImage* srcImage = [self imageForRanges:ranges imageRect:&srcRect]; NSImage* image = [[NSImage alloc] initWithSize:srcImage.size]; [image lockFocus]; [srcImage drawAtPoint:NSZeroPoint fromRect:NSZeroRect operation:NSCompositeCopy fraction:0.5]; [image unlockFocus]; std::vector v; for(auto const& range : ranges) v.push_back(document->buffer().substr(range.min().index, range.max().index)); NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard]; [pboard declareTypes:@[ NSStringPboardType ] owner:self]; [pboard setString:[NSString stringWithCxxString:text::join(v, "\n")] forType:NSStringPboardType]; [self dragImage:image at:NSMakePoint(NSMinX(srcRect), NSMaxY(srcRect)) offset:NSZeroSize event:anEvent pasteboard:pboard source:self slideBack:YES]; self.showDragCursor = NO; } - (BOOL)acceptsFirstMouse:(NSEvent*)anEvent { BOOL res = [self isPointInSelection:[self convertPoint:[anEvent locationInWindow] fromView:nil]]; D(DBF_OakTextView_MouseEvents, bug("%s\n", BSTR(res));); return res; } - (BOOL)shouldDelayWindowOrderingForEvent:(NSEvent*)anEvent { BOOL res = [self isPointInSelection:[self convertPoint:[anEvent locationInWindow] fromView:nil]]; D(DBF_OakTextView_MouseEvents, bug("%s\n", BSTR(res));); return res; } - (void)changeToDragPointer:(NSTimer*)aTimer { self.initiateDragTimer = nil; delayMouseDown = NO; self.showDragCursor = YES; } - (int)dragDelay { id dragDelayObj = [[NSUserDefaults standardUserDefaults] objectForKey:@"NSDragAndDropTextDelay"]; return [dragDelayObj respondsToSelector:@selector(intValue)] ? [dragDelayObj intValue] : 150; } - (void)preparePotentialDrag:(NSEvent*)anEvent { if([self dragDelay] != 0 && ([[self window] isKeyWindow] || ([anEvent modifierFlags] & NSCommandKeyMask))) self.initiateDragTimer = [OakTimer scheduledTimerWithTimeInterval:(0.001 * [self dragDelay]) target:self selector:@selector(changeToDragPointer:) repeats:NO]; else [self changeToDragPointer:nil]; delayMouseDown = [[self window] isKeyWindow]; } static scope::context_t add_modifiers_to_scope (scope::context_t scope, NSUInteger modifiers) { static struct { NSUInteger modifier; char const* scope; } const map[] = { { NSShiftKeyMask, "dyn.modifier.shift" }, { NSControlKeyMask, "dyn.modifier.control" }, { NSAlternateKeyMask, "dyn.modifier.option" }, { NSCommandKeyMask, "dyn.modifier.command" } }; for(auto const& it : map) { if(modifiers & it.modifier) { scope.left.push_scope(it.scope); scope.right.push_scope(it.scope); } } return scope; } - (void)mouseDown:(NSEvent*)anEvent { if(!layout || [anEvent type] != NSLeftMouseDown || ignoreMouseDown) return (void)(ignoreMouseDown = NO); if(ng::range_t r = layout->folded_range_at_point([self convertPoint:[anEvent locationInWindow] fromView:nil])) { layout->unfold(r.min().index, r.max().index); return; } if(macroRecordingArray && [anEvent type] == NSLeftMouseDown) { NSInteger choice = NSRunAlertPanel(@"You are recording a macro", @"While recording macros it is not possible to select text or reposition the caret using your mouse.", @"Continue", @"Stop Recording", nil); if(choice == NSAlertAlternateReturn) // "Stop Macro Recording" self.isMacroRecording = NO; return; } std::vector const& items = bundles::query(bundles::kFieldSemanticClass, "callback.mouse-click", add_modifiers_to_scope(ng::scope(document->buffer(), layout->index_at_point([self convertPoint:[anEvent locationInWindow] fromView:nil]), to_s([self scopeAttributes])), [anEvent modifierFlags])); if(!items.empty()) { if(bundles::item_ptr item = OakShowMenuForBundleItems(items, [self positionForWindowUnderCaret])) { AUTO_REFRESH; editor->set_selections(ng::range_t(layout->index_at_point([self convertPoint:[anEvent locationInWindow] fromView:nil]).index)); [self performBundleItem:item]; } return; } AUTO_REFRESH; mouseDownPos = [self convertPoint:[anEvent locationInWindow] fromView:nil]; mouseDownClickCount = [anEvent clickCount]; mouseDownModifierFlags = [anEvent modifierFlags]; BOOL hasFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); if(!hasFocus) mouseDownModifierFlags &= ~NSCommandKeyMask; if(!(mouseDownModifierFlags & NSShiftKeyMask) && [self isPointInSelection:[self convertPoint:[anEvent locationInWindow] fromView:nil]] && [anEvent clickCount] == 1 && [self dragDelay] >= 0 && !([anEvent modifierFlags] & (NSShiftKeyMask | NSControlKeyMask | NSAlternateKeyMask | NSCommandKeyMask))) [self preparePotentialDrag:anEvent]; else [self actOnMouseDown]; } - (void)mouseDragged:(NSEvent*)anEvent { if(!layout || macroRecordingArray) return; NSPoint mouseCurrentPos = [self convertPoint:[anEvent locationInWindow] fromView:nil]; if(SQ(fabs(mouseDownPos.x - mouseCurrentPos.x)) + SQ(fabs(mouseDownPos.y - mouseCurrentPos.y)) < SQ(1)) return; // we didn't even drag a pixel delayMouseDown = NO; if(showDragCursor) { [self startDragForEvent:anEvent]; } else if(initiateDragTimer) // delayed reaction to mouseDown { self.initiateDragTimer = nil; AUTO_REFRESH; [self actOnMouseDown]; } else { if(!dragScrollTimer && [self autoscroll:[NSApp currentEvent]] == YES) self.dragScrollTimer = [OakTimer scheduledTimerWithTimeInterval:(1.0/25.0) target:self selector:@selector(dragScrollTimerFired:) repeats:YES]; AUTO_REFRESH; [self actOnMouseDragged:anEvent]; } } - (void)dragScrollTimerFired:(id)sender { AUTO_REFRESH; [self actOnMouseDragged:[NSApp currentEvent]]; } - (void)mouseUp:(NSEvent*)anEvent { if(!layout || macroRecordingArray) return; AUTO_REFRESH; if(delayMouseDown) [self actOnMouseDown]; delayMouseDown = NO; self.initiateDragTimer = nil; self.dragScrollTimer = nil; self.showDragCursor = NO; } // =================== // = Change in Focus = // =================== - (void)setKeyState:(NSUInteger)newState { BOOL didHaveFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); [super setKeyState:newState]; BOOL doesHaveFocus = (self.keyState & (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask)) == (OakViewViewIsFirstResponderMask|OakViewWindowIsKeyMask|OakViewApplicationIsActiveMask); if(didHaveFocus == doesHaveFocus) return; if(doesHaveFocus) { [[NSFontManager sharedFontManager] setSelectedFont:self.font isMultiple:NO]; [self setShowLiveSearch:NO]; } else { self.showColumnSelectionCursor = showDragCursor = NO; [[self window] invalidateCursorRectsForView:self]; } if(layout) { AUTO_REFRESH; layout->set_draw_caret(doesHaveFocus); layout->set_is_key(doesHaveFocus); hideCaret = !doesHaveFocus; } self.blinkCaretTimer = doesHaveFocus ? [NSTimer scheduledTimerWithTimeInterval:[NSEvent caretBlinkInterval] target:self selector:@selector(toggleCaretVisibility:) userInfo:nil repeats:YES] : nil; } // =========== // = Actions = // =========== - (void)handleAction:(ng::action_t)anAction forSelector:(SEL)aSelector { AUTO_REFRESH; [self recordSelector:aSelector withArgument:nil]; try { editor->perform(anAction, layout.get(), [self indentCorrections], to_s([self scopeAttributes])); static std::set const SilentActions = { ng::kCopy, ng::kCopySelectionToFindPboard, ng::kCopySelectionToReplacePboard, ng::kCopySelectionToYankPboard, ng::kAppendSelectionToYankPboard, ng::kPrependSelectionToYankPboard, ng::kSetMark, ng::kNop }; if(SilentActions.find(anAction) == SilentActions.end()) self.needsEnsureSelectionIsInVisibleArea = YES; } catch(std::exception const& e) { crash_reporter_info_t info(text::format("Performing @selector(%s)\nC++ Exception: %s", sel_getName(aSelector), e.what())); abort(); } } #define ACTION(NAME) (void)NAME:(id)sender { [self handleAction:ng::to_action(#NAME ":") forSelector:@selector(NAME:)]; } #define ALIAS(NAME, REAL) (void)NAME:(id)sender { [self handleAction:ng::to_action(#REAL ":") forSelector:@selector(REAL:)]; } // ========================= // = Scroll Action Methods = // ========================= - (void)scrollLineUp:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], 0, -17)]; } // TODO Query layout for scroll increments - (void)scrollLineDown:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], 0, +17)]; } // TODO Query layout for scroll increments - (void)scrollColumnLeft:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], -7, 0)]; } // TODO Query layout for scroll increments - (void)scrollColumnRight:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], +7, 0)]; } // TODO Query layout for scroll increments - (void)scrollPageUp:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], 0, -NSHeight([self visibleRect]))]; } - (void)scrollPageDown:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:NSOffsetRect([self visibleRect], 0, +NSHeight([self visibleRect]))]; } - (void)scrollToBeginningOfDocument:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:(NSRect){ NSZeroPoint, [self visibleRect].size }]; } - (void)scrollToEndOfDocument:(id)sender { [self recordSelector:_cmd withArgument:nil]; [self scrollRectToVisible:(NSRect){ { 0, NSMaxY([self bounds]) - NSHeight([self visibleRect]) }, [self visibleRect].size }]; } // ======== // = Move = // ======== - ACTION(moveBackward); - ACTION(moveBackwardAndModifySelection); - ACTION(moveDown); - ACTION(moveDownAndModifySelection); - ACTION(moveForward); - ACTION(moveForwardAndModifySelection); - ACTION(moveParagraphBackwardAndModifySelection); - ACTION(moveParagraphForwardAndModifySelection); - ACTION(moveSubWordLeft); - ACTION(moveSubWordLeftAndModifySelection); - ACTION(moveSubWordRight); - ACTION(moveSubWordRightAndModifySelection); - ACTION(moveToBeginningOfColumn); - ACTION(moveToBeginningOfColumnAndModifySelection); - ACTION(moveToBeginningOfDocument); - ACTION(moveToBeginningOfDocumentAndModifySelection); - ACTION(moveToBeginningOfIndentedLine); - ACTION(moveToBeginningOfIndentedLineAndModifySelection); - ACTION(moveToBeginningOfLine); - ACTION(moveToBeginningOfLineAndModifySelection); - ACTION(moveToBeginningOfParagraph); - ACTION(moveToBeginningOfParagraphAndModifySelection); - ACTION(moveToBeginningOfBlock); - ACTION(moveToBeginningOfBlockAndModifySelection); - ACTION(moveToEndOfColumn); - ACTION(moveToEndOfColumnAndModifySelection); - ACTION(moveToEndOfDocument); - ACTION(moveToEndOfDocumentAndModifySelection); - ACTION(moveToEndOfIndentedLine); - ACTION(moveToEndOfIndentedLineAndModifySelection); - ACTION(moveToEndOfLine); - ACTION(moveToEndOfLineAndModifySelection); - ACTION(moveToEndOfParagraph); - ACTION(moveToEndOfParagraphAndModifySelection); - ACTION(moveToEndOfBlock); - ACTION(moveToEndOfBlockAndModifySelection); - ACTION(moveUp); - ACTION(moveUpAndModifySelection); - ACTION(moveWordBackward); - ACTION(moveWordBackwardAndModifySelection); - ACTION(moveWordForward); - ACTION(moveWordForwardAndModifySelection); - ALIAS(moveLeft, moveBackward); - ALIAS(moveRight, moveForward); - ALIAS(moveLeftAndModifySelection, moveBackwardAndModifySelection); - ALIAS(moveRightAndModifySelection, moveForwardAndModifySelection); - ALIAS(moveWordLeft, moveWordBackward); - ALIAS(moveWordLeftAndModifySelection, moveWordBackwardAndModifySelection); - ALIAS(moveWordRight, moveWordForward); - ALIAS(moveWordRightAndModifySelection, moveWordForwardAndModifySelection); - ALIAS(moveToLeftEndOfLine, moveToBeginningOfLine); - ALIAS(moveToLeftEndOfLineAndModifySelection, moveToBeginningOfLineAndModifySelection); - ALIAS(moveToRightEndOfLine, moveToEndOfLine); - ALIAS(moveToRightEndOfLineAndModifySelection, moveToEndOfLineAndModifySelection); - ACTION(pageDown); - ACTION(pageDownAndModifySelection); - ACTION(pageUp); - ACTION(pageUpAndModifySelection); // ========== // = Select = // ========== - ACTION(toggleColumnSelection); - ACTION(selectAll); - ACTION(selectCurrentScope); - ACTION(selectBlock); - ACTION(selectHardLine); - ACTION(selectLine); - ACTION(selectParagraph); - ACTION(selectWord); // ========== // = Delete = // ========== - ALIAS(delete, deleteSelection); - ACTION(deleteBackward); - ACTION(deleteForward); - ACTION(deleteSubWordLeft); - ACTION(deleteSubWordRight); - ACTION(deleteToBeginningOfIndentedLine); - ACTION(deleteToBeginningOfLine); - ACTION(deleteToBeginningOfParagraph); - ACTION(deleteToEndOfIndentedLine); - ACTION(deleteToEndOfLine); - ACTION(deleteToEndOfParagraph); - ACTION(deleteWordBackward); - ACTION(deleteWordForward); - ACTION(deleteBackwardByDecomposingPreviousCharacter); // ============= // = Clipboard = // ============= - ACTION(cut); - ACTION(copy); - ACTION(copySelectionToFindPboard); - ACTION(copySelectionToReplacePboard); - ACTION(paste); - ACTION(pastePrevious); - ACTION(pasteNext); - ACTION(pasteWithoutReindent); - ACTION(yank); // ============= // = Transform = // ============= - ACTION(capitalizeWord); - ACTION(changeCaseOfLetter); - ACTION(changeCaseOfWord); - ACTION(lowercaseWord); - ACTION(reformatText); - ACTION(reformatTextAndJustify); - ACTION(shiftLeft); - ACTION(shiftRight); - ACTION(transpose); - ACTION(transposeWords); - ACTION(unwrapText); - ACTION(uppercaseWord); // ========= // = Marks = // ========= - ACTION(setMark); - ACTION(deleteToMark); - ACTION(selectToMark); - ACTION(swapWithMark); // ============== // = Completion = // ============== - ACTION(complete); - ACTION(nextCompletion); - ACTION(previousCompletion); // ============= // = Insertion = // ============= - ACTION(insertBacktab); - ACTION(insertTabIgnoringFieldEditor); - ACTION(insertNewline); - ACTION(insertNewlineIgnoringFieldEditor); // =========== // = Complex = // =========== - ACTION(indent); - ACTION(moveSelectionUp); - ACTION(moveSelectionDown); - ACTION(moveSelectionLeft); - ACTION(moveSelectionRight); @end