From 38feadce9cb93805cc996c10429868edc47e83c9 Mon Sep 17 00:00:00 2001 From: Yee Cheng Chin Date: Wed, 12 Oct 2022 17:46:41 -0700 Subject: [PATCH 1/2] Make data lookup support selected text Doing data lookup (e.g. Ctrl-Cmd-D) on top of a selected text now properly look up the entire selected range (e.g. "ice cream", instead of just "ice" or "cream"). This would have come by default if we could implement NSTextInputClient's selectedRange properly but since MacVim doesn't have access to the internal Vim buffers easily this is easier said than done. As such, we have a custom implementation where if we detect a lookup event, manually detect that we have the mouse cursor on top of selected text and show the definition for that manually. Also fix a minor issue in text input client so that the baseline is now reported correctly for certan font size comboes, as our fontDescent is rounded up for some reason. --- src/MacVim/MMBackend.m | 104 ++++++++++++++++++++++++++++++++++++ src/MacVim/MMCoreTextView.h | 5 +- src/MacVim/MMCoreTextView.m | 75 +++++++++++++++++++++++++- src/MacVim/MacVim.h | 2 + src/mouse.c | 2 +- src/proto/mouse.pro | 4 ++ 6 files changed, 189 insertions(+), 3 deletions(-) diff --git a/src/MacVim/MMBackend.m b/src/MacVim/MMBackend.m index 8e1b6f2953..a891c81491 100644 --- a/src/MacVim/MMBackend.m +++ b/src/MacVim/MMBackend.m @@ -1486,6 +1486,110 @@ - (BOOL)selectedTextToPasteboard:(byref NSPasteboard *)pboard return NO; } +/// Returns the currently selected text. We should consolidate this with +/// selectedTextToPasteboard: above when we have time. (That function has a +/// fast path just to query whether selected text exists) +- (NSString *)selectedText +{ + if (VIsual_active && (State & MODE_NORMAL)) { + char_u *str = extractSelectedText(); + if (!str) + return nil; + + if (output_conv.vc_type != CONV_NONE) { + char_u *conv_str = string_convert(&output_conv, str, NULL); + if (conv_str) { + vim_free(str); + str = conv_str; + } + } + + NSString *string = [[NSString alloc] initWithUTF8String:(char*)str]; + vim_free(str); + return [string autorelease]; + } + return nil; +} + +/// Returns whether the provided mouse screen position is on a visually +/// selected range of text. +/// +/// If yes, also return the starting row/col of the selection. +- (BOOL)mouseScreenposIsSelection:(int)row column:(int)column selRow:(byref int *)startRow selCol:(byref int *)startCol +{ + // The code here is adopted from mouse.c's handling of popup_setpos. + // Unfortunately this logic is a little tricky to do in pure Vim script + // because there isn't a function to allow you to query screen pos to + // window pos. Even getmousepos() doesn't work the way you expect it to if + // you click on the placeholder rows after the last line (they all return + // the same 'column'). + if (!VIsual_active) + return NO; + + // We set mouse_row / mouse_col without caching/restoring, because it + // hoenstly makes sense to update them. If in the future we want a version + // that isn't mouse-related, then we may want to resotre them at the end of + // the function. + mouse_row = row; + mouse_col = column; + + pos_T m_pos; + + if (mouse_row < curwin->w_winrow + || mouse_row > (curwin->w_winrow + curwin->w_height)) + { + return NO; + } + else if (get_fpos_of_mouse(&m_pos) != IN_BUFFER) + { + return NO; + } + else if (VIsual_mode == 'V') + { + if ((curwin->w_cursor.lnum <= VIsual.lnum + && (m_pos.lnum < curwin->w_cursor.lnum + || VIsual.lnum < m_pos.lnum)) + || (VIsual.lnum < curwin->w_cursor.lnum + && (m_pos.lnum < VIsual.lnum + || curwin->w_cursor.lnum < m_pos.lnum))) + { + return NO; + } + } + else if ((LTOREQ_POS(curwin->w_cursor, VIsual) + && (LT_POS(m_pos, curwin->w_cursor) + || LT_POS(VIsual, m_pos))) + || (LT_POS(VIsual, curwin->w_cursor) + && (LT_POS(m_pos, VIsual) + || LT_POS(curwin->w_cursor, m_pos)))) + { + return NO; + } + else if (VIsual_mode == Ctrl_V) + { + colnr_T leftcol, rightcol; + getvcols(curwin, &curwin->w_cursor, &VIsual, + &leftcol, &rightcol); + getvcol(curwin, &m_pos, NULL, &m_pos.col, NULL); + if (m_pos.col < leftcol || m_pos.col > rightcol) + return NO; + } + + // Now, also return the selection's coordinates back to caller + pos_T* visualStart = LT_POS(curwin->w_cursor, VIsual) ? &curwin->w_cursor : &VIsual; + int srow = 0; + int scol = 0, ccol = 0, ecol = 0; + textpos2screenpos(curwin, visualStart, &srow, &scol, &ccol, &ecol); + srow = srow > 0 ? srow - 1 : 0; // convert from 1-indexed to 0-indexed. + scol = scol > 0 ? scol - 1 : 0; + if (VIsual_mode == 'V') + scol = 0; + *startRow = srow; + *startCol = scol; + + return YES; +} + - (oneway void)addReply:(in bycopy NSString *)reply server:(in byref id )server { diff --git a/src/MacVim/MMCoreTextView.h b/src/MacVim/MMCoreTextView.h index 5f1aab2367..eb57b45e94 100644 --- a/src/MacVim/MMCoreTextView.h +++ b/src/MacVim/MMCoreTextView.h @@ -79,7 +79,7 @@ NSString* toolTip_; } -- (id)initWithFrame:(NSRect)frame; +- (instancetype)initWithFrame:(NSRect)frame; // // NSFontChanging methods @@ -145,6 +145,7 @@ // NSTextView methods // - (void)keyDown:(NSEvent *)event; +- (void)quickLookWithEvent:(NSEvent *)event; // // NSTextInputClient methods @@ -161,6 +162,8 @@ - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(nullable NSRangePointer)actualRange; - (NSUInteger)characterIndexForPoint:(NSPoint)point; +- (CGFloat)baselineDeltaForCharacterAtIndex:(NSUInteger)anIndex; + // // NSTextContainer methods // diff --git a/src/MacVim/MMCoreTextView.m b/src/MacVim/MMCoreTextView.m index 21475608f2..9bff7400dc 100644 --- a/src/MacVim/MMCoreTextView.m +++ b/src/MacVim/MMCoreTextView.m @@ -223,7 +223,7 @@ @implementation MMCoreTextView { int cmdlineRow; ///< Row number (0-indexed) where the cmdline starts. Used for pinning it to the bottom if desired. } -- (id)initWithFrame:(NSRect)frame +- (instancetype)initWithFrame:(NSRect)frame { if (!(self = [super initWithFrame:frame])) return nil; @@ -1597,6 +1597,12 @@ - (NSUInteger)characterIndexForPoint:(NSPoint)point return utfCharIndexFromRowCol(&grid, row, col); } +/// Returns the cursor location in the text storage. Note that the API is +/// supposed to return a range if there are selected texts, but since we don't +/// have access to the full text storage in MacVim (it requires IPC calls to +/// Vim), we just return the cursor with the range always having zero length. +/// This affects the quickLookWithEvent: implementation where we have to +/// manually handle the selected text case. - (NSRange)selectedRange { if ([helper hasMarkedText]) { @@ -1667,8 +1673,75 @@ - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(nullable NSRang } } +/// Optional function in text input client. Returns the proper baseline delta +/// for the returned rect. We need to do this because we take the ceil() of +/// fontDescent, which subtly changes the baseline relative to what the OS thinks, +/// and would have resulted in a slightly offset text under certain fonts/sizes. +- (CGFloat)baselineDeltaForCharacterAtIndex:(NSUInteger)anIndex +{ + // Note that this function is calculated top-down, so we need to subtract from height. + return cellSize.height - fontDescent; +} + #pragma endregion // Text Input Client +/// Perform data lookup. This gets called by the OS when the user uses +/// Ctrl-Cmd-D or the trackpad to look up data. +- (void)quickLookWithEvent:(NSEvent *)event +{ + // The default implementation would query using the NSTextInputClient API + // which works fine. + // + // However, by default, if there are texts that are selected, *and* the + // user performs lookup when the mouse is on top of said selected text, the + // OS will use that for the lookup instead. E.g. if the user has selected + // "ice cream" and perform a lookup on it, the lookup will be "ice cream" + // instead of "ice" or "cream". We need to implement this in a custom + // fashion because our `selectedRange` implementation doesn't properly + // return the selected text (which we cannot do easily since our text + // storage isn't representative of the Vim's internal buffer, see above + // design notes), by querying Vim for the selected text manually. + + MMVimController *vc = [self vimController]; + id backendProxy = [vc backendProxy]; + if ([backendProxy selectedTextToPasteboard:nil]) { + // We have selected text. Proceed to see if the mouse is directly on + // top of said selection and if so, show definition of that instead. + const NSPoint pt = [self convertPoint:[event locationInWindow] fromView:nil]; + int row, col; + if ([self convertPoint:pt toRow:&row column:&col]) { + int selRow = 0, selCol = 0; + const BOOL isMouseInSelection = [backendProxy mouseScreenposIsSelection:row column:col selRow:&selRow selCol:&selCol]; + + if (isMouseInSelection) { + NSString *selectedText = [backendProxy selectedText]; + if (selectedText) { + NSAttributedString *attrText = [[[NSAttributedString alloc] initWithString:selectedText + attributes:@{NSFontAttributeName: font} + ] autorelease]; + + const NSRect selRect = [self rectForRow:selRow + column:selCol + numRows:1 + numColumns:1]; + + NSPoint baselinePt = selRect.origin; + baselinePt.y += fontDescent; + + // We have everything we need. Just show the definition and return. + [self showDefinitionForAttributedString:attrText atPoint:baselinePt]; + return; + } + } + } + } + + // Just call the default implementation, which will call misc + // NSTextInputClient methods on us and use that to determine what/where to + // show. + [super quickLookWithEvent:event]; +} + @end // MMCoreTextView diff --git a/src/MacVim/MacVim.h b/src/MacVim/MacVim.h index e549b436e3..e3a99baed6 100644 --- a/src/MacVim/MacVim.h +++ b/src/MacVim/MacVim.h @@ -146,6 +146,8 @@ - (id)evaluateExpressionCocoa:(in bycopy NSString *)expr errorString:(out bycopy NSString **)errstr; - (BOOL)selectedTextToPasteboard:(byref NSPasteboard *)pboard; +- (NSString *)selectedText; +- (BOOL)mouseScreenposIsSelection:(int)row column:(int)column selRow:(byref int *)startRow selCol:(byref int *)startCol; - (oneway void)acknowledgeConnection; @end diff --git a/src/mouse.c b/src/mouse.c index 5481b73d8c..9e773fa668 100644 --- a/src/mouse.c +++ b/src/mouse.c @@ -146,7 +146,7 @@ find_end_of_word(pos_T *pos) * Returns IN_BUFFER and sets "mpos->col" to the column when in buffer text. * The column is one for the first column. */ - static int + int get_fpos_of_mouse(pos_T *mpos) { win_T *wp; diff --git a/src/proto/mouse.pro b/src/proto/mouse.pro index 249e7f2408..76c067dcbb 100644 --- a/src/proto/mouse.pro +++ b/src/proto/mouse.pro @@ -21,4 +21,8 @@ int mouse_comp_pos(win_T *win, int *rowp, int *colp, linenr_T *lnump, int *pline win_T *mouse_find_win(int *rowp, int *colp, mouse_find_T popup); int vcol2col(win_T *wp, linenr_T lnum, int vcol); void f_getmousepos(typval_T *argvars, typval_T *rettv); + +// MacVim-only +int get_fpos_of_mouse(pos_T *mpos); + /* vim: set ft=c : */ From d8d7df81adbdfe3e5ca058d82b8210a54bd39e5c Mon Sep 17 00:00:00 2001 From: Yee Cheng Chin Date: Wed, 12 Oct 2022 19:34:16 -0700 Subject: [PATCH 2/2] Add data detector support for lookup, so URLs etc will work properly This automatically uses NSDataDetector to detect special data around the lookup cursor position, and if found, will manually call showDefinition instead of letting the OS do it for us (honestly, this feature should be built-in to the OS instead of such manual work). Right now only doing address/phone number/URL (URL has priority), because we don't have built-in definition support for the other types like flight info and so on. This also only works for what is drawn on-screen only, because as usual, MacVim doesn't have access to the native text storage, unless we rely on lots of callbacks back-and-forth (which is possible, but annoying to implement). --- src/MacVim/MMCoreTextView.m | 93 +++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/src/MacVim/MMCoreTextView.m b/src/MacVim/MMCoreTextView.m index 9bff7400dc..425d96672f 100644 --- a/src/MacVim/MMCoreTextView.m +++ b/src/MacVim/MMCoreTextView.m @@ -1687,6 +1687,10 @@ - (CGFloat)baselineDeltaForCharacterAtIndex:(NSUInteger)anIndex /// Perform data lookup. This gets called by the OS when the user uses /// Ctrl-Cmd-D or the trackpad to look up data. +/// +/// This implementation will default to using the OS's implementation, +/// but also perform special checking for selected text, and perform data +/// detection for URLs, etc. - (void)quickLookWithEvent:(NSEvent *)event { // The default implementation would query using the NSTextInputClient API @@ -1701,15 +1705,23 @@ - (void)quickLookWithEvent:(NSEvent *)event // return the selected text (which we cannot do easily since our text // storage isn't representative of the Vim's internal buffer, see above // design notes), by querying Vim for the selected text manually. - - MMVimController *vc = [self vimController]; - id backendProxy = [vc backendProxy]; - if ([backendProxy selectedTextToPasteboard:nil]) { - // We have selected text. Proceed to see if the mouse is directly on + // + // Another custom implementation we do is by first feeding the data through + // an NSDataDetector first. This helps us catch URLs, addresses, and so on. + // Otherwise for an URL, it will not include the whole https:// part and + // won't show a web page. Note that NSTextView/WebKit/etc all use an + // internal API called Reveal which does this for free and more powerful, + // but we don't have access to that as a third-party software that + // implements a custom text view. + + const NSPoint pt = [self convertPoint:[event locationInWindow] fromView:nil]; + int row = 0, col = 0; + if ([self convertPoint:pt toRow:&row column:&col]) { + // 1. If we have selected text. Proceed to see if the mouse is directly on // top of said selection and if so, show definition of that instead. - const NSPoint pt = [self convertPoint:[event locationInWindow] fromView:nil]; - int row, col; - if ([self convertPoint:pt toRow:&row column:&col]) { + MMVimController *vc = [self vimController]; + id backendProxy = [vc backendProxy]; + if ([backendProxy selectedTextToPasteboard:nil]) { int selRow = 0, selCol = 0; const BOOL isMouseInSelection = [backendProxy mouseScreenposIsSelection:row column:col selRow:&selRow selCol:&selCol]; @@ -1734,6 +1746,71 @@ - (void)quickLookWithEvent:(NSEvent *)event } } } + + // 2. Check if we have specialized data. Honestly the OS should really do this + // for us as we are just calling text input client APIs here. + const NSUInteger charIndex = utfCharIndexFromRowCol(&grid, row, col); + NSTextCheckingTypes checkingTypes = NSTextCheckingTypeAddress + | NSTextCheckingTypeLink + | NSTextCheckingTypePhoneNumber; + // | NSTextCheckingTypeDate // Date doesn't really work for showDefinition without private APIs + // | NSTextCheckingTypeTransitInformation // Flight info also doesn't work without private APIs + NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:checkingTypes error:nil]; + if (detector != nil) { + // Just check [-100,100) around the mouse cursor. That should be more than enough to find interesting information. + const NSUInteger rangeSize = 100; + const NSUInteger rangeOffset = charIndex > rangeSize ? rangeSize : charIndex; + const NSRange checkRange = NSMakeRange(charIndex - rangeOffset, charIndex + rangeSize * 2); + + NSAttributedString *attrStr = [self attributedSubstringForProposedRange:checkRange actualRange:nil]; + + __block NSUInteger count = 0; + __block NSRange foundRange = NSMakeRange(0, 0); + [detector enumerateMatchesInString:attrStr.string + options:0 + range:NSMakeRange(0, attrStr.length) + usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ + if (++count >= 30) { + // Sanity checking + *stop = YES; + } + + NSRange matchRange = [match range]; + if (!NSLocationInRange(rangeOffset, matchRange)) { + // We found something interesting nearby, but it's not where the mouse cursor is, just move on. + return; + } + if (match.resultType == NSTextCheckingTypeLink) { + foundRange = matchRange; + *stop = YES; // URL is highest priority, so we always terminate. + } else if (match.resultType == NSTextCheckingTypePhoneNumber || match.resultType == NSTextCheckingTypeAddress) { + foundRange = matchRange; + } + }]; + + if (foundRange.length != 0) { + // We found something interesting! Show that instead of going through the default OS behavior. + NSUInteger startIndex = charIndex + foundRange.location - rangeOffset; + + int row = 0, col = 0, firstLineNumCols = 0, firstLineUtf8Len = 0; + rowColFromUtfRange(&grid, NSMakeRange(startIndex, 0), &row, &col, &firstLineNumCols, &firstLineUtf8Len); + const NSRect rectToShow = [self rectForRow:row + column:col + numRows:1 + numColumns:1]; + + NSPoint baselinePt = rectToShow.origin; + baselinePt.y += fontDescent; + + [self showDefinitionForAttributedString:attrStr + range:foundRange + options:@{} + baselineOriginProvider:^NSPoint(NSRange adjustedRange) { + return baselinePt; + }]; + return; + } + } } // Just call the default implementation, which will call misc