From 2928f777a97ac2a12c5663ece29a24bb030dddc6 Mon Sep 17 00:00:00 2001 From: Yee Cheng Chin Date: Tue, 11 Oct 2022 12:46:55 -0700 Subject: [PATCH] Support dictionary/data lookups of text This adds support for looking up data under the mouse cursor. Usually it will bring up a dictionary, but other times it could be a Wikipedia article, Siri knowledge, etc. Apple doesn't really have a good name for it, other than "looking up data", "quick look" (a confusingly similar name with the other Quick Look OS feature), or "show definition". You can activate this by doing Ctrl-Cmd-D when the mouse is over a cursor. If you have a trackpad, you can also either activate this using Force click or three-finger tap (depends on your system preference settings). Note that for Force click, this could potentially make it impossible to use the MacVim `` mapping in Vim, which allows you to map a force click to a Vim command (#716). This is handled by having a new setting (under a new "Input" preference pane which will have more populated later) that allows you to choose whether to use Force click for data lookup or Vim's `` mapping. If you have configured to use three-finger taps though this setting wouldn't do anything, and `` is always send to the Vim mapping. Also, this is lacking a lot of features that a normal macOS application would get, e.g. looking up selected texts (e.g. if you have "ice cream", you may want to select the whole thing to look up the phrase, rather than just "ice" or "cream"), data detector, and much more (e.g. custom API support). They will be done later as part of #1311. Technical details below: The way the OS knows how to look up the data and present it is by talking to the NSTextInput/NSTextInputClient. Previously MacVim implemented NSTextInput partially, and didn't implement the critical firstRectForCharacterRange:actualRange and characterIndexForPoint: functions. First, in this change we change from NSTextInput to NSTextInputClient (which is the newer non-deprecated version), and implement those functions, which allows the OS to query the text storage. By default, the OS sends a quickLookWithEvent: call to us whenever the lookup happens but for some odd reason this isn't automatic for Force clicks, presumably because some apps want to handle Force clicks manually (this is why some apps only work for three-finger taps but not Force clicks for lookups). This isn't documented but I found references in iTerm/Firefox, and basically we just need to manually handle it and send off quickLookWithEvent: when handling Force clicks. For implementing the NSTextInputClient properly, the main issue is making sure that can work properly with input methods / marked texts, which is the other primary purpose for this class (other than inputting keys). For data lookups, I'm representing the grid as a row-major text (with no newline/space in between) and expose that to the OS. This already has some issue because it doesn't handle Vim vertical splits well, as MacVim doesn't really have access to detailed Vim text buffers easily (unless we do a lot of calls back-and-forth). This means wrapped texts won't be looked up properly, which I think is ok. Also, the OS APIs deal with UTF-8 indices, so we can't just convert row/column to raw indices and have to do a lot of character length calculations (especially for wide chars like CJK or emojis) to make sure the returned ranges are consistent and valid. For marked texts though, this presents a challenge because Vim doesn't really have a strong enough API to communicate back-and-forth about the marked positions and whatnot (it only let the GUI know where the current cursor is), and it's hard to implement APIs like `markedRange` properly because some marked texts could be hidden or wrapped (if you implement some of these functions improperly Apple's input methods could start misbehaving especially when you use arrow keys to navigate). In the end I kept the original implementation for treating the marked texts as a range starting from 0, *only* when we have marked text. Kind of a hack but this makes sure we work both in marked text mode (i.e. when inputting texts) and when doing lookups. For simplicity I made it so that you can't do data lookups when in marked text mode now. Data detection: Note that the default implementation is quite bare, and lacks a lot of smart data detection. For example, if you put your mouse over a URL, it won't properly select the whole URL, and addresses and dates for example also won't get grouped together properly. This is because these require additional implementation (e.g. using NSDataDetector) instead of coming "for free", and will be handled later. In fact, Apple's WebKit and NSTextView cheats by calling an internal API framework called "Reveal" (which you can find out by intercepting NSTextView's calls and/or looking at WebKit's source code) which is much more powerful and supports looking up package tracking, airline info, and more, but it's not available to third-party software (that's why Safari's lookup is so much better than Chrome/Firefox's). This isn't tested right now. Future task needs to add XCTest support to properly test this as there are a lot of edge cases involved here. Fix #1191 Part of Epic #1311, which contains other items to be implemented. --- runtime/doc/gui_mac.txt | 12 +- runtime/doc/tags | 1 + src/MacVim/Base.lproj/Preferences.xib | 36 +++ src/MacVim/MMAppController.m | 1 + src/MacVim/MMCoreTextView.h | 40 ++- src/MacVim/MMCoreTextView.m | 442 +++++++++++++++++++++++--- src/MacVim/MMPreferenceController.h | 4 + src/MacVim/MMPreferenceController.m | 19 ++ src/MacVim/MMTextViewHelper.h | 5 +- src/MacVim/MMTextViewHelper.m | 51 ++- src/MacVim/Miscellaneous.h | 1 + src/MacVim/Miscellaneous.m | 1 + 12 files changed, 558 insertions(+), 55 deletions(-) diff --git a/runtime/doc/gui_mac.txt b/runtime/doc/gui_mac.txt index e0b519d4b6..65fc6551ae 100644 --- a/runtime/doc/gui_mac.txt +++ b/runtime/doc/gui_mac.txt @@ -259,6 +259,8 @@ as general information regarding macOS user defaults. Here is a list of relevant dictionary entries: KEY VALUE ~ +*MMAllowForceClickLookUp* use Force click for data lookup instead of + [bool] *MMCellWidthMultiplier* width of a normal glyph in em units [float] *MMCmdLineAlignBottom* Pin command-line to bottom of MacVim [bool] *MMDialogsTrackPwd* open/save dialogs track the Vim pwd [bool] @@ -783,11 +785,17 @@ Each gesture generates one of the following Vim pseudo keys: ** ** Generated when swiping three fingers across the trackpad in a - vertical direction. (Not supported by the Apple Magic Mouse.) + vertical direction. (Not supported by the Apple Magic Mouse) ** Generated when doing a Force click by pressing hard on a trackpad. - (Only supported on trackpads that support Force Touch.) + (Only supported on trackpads that support Force Touch) + + If you have configured to use Force click for "Look up & data + detectors" in the system settings, by default MacVim will do a + dictionary lookup instead of triggering this mapping. You can turn + this off in MacVim's Preference pane, or directly set + |MMAllowForceClickLookUp|. You can map these keys like with any other key using the |:map| family of commands. For example, the following commands map left/right swipe to change diff --git a/runtime/doc/tags b/runtime/doc/tags index 0bbaac575f..57e18d71b6 100644 --- a/runtime/doc/tags +++ b/runtime/doc/tags @@ -5417,6 +5417,7 @@ LogiPat-flags pi_logipat.txt /*LogiPat-flags* Lua if_lua.txt /*Lua* M motion.txt /*M* MDI starting.txt /*MDI* +MMAllowForceClickLookUp gui_mac.txt /*MMAllowForceClickLookUp* MMAppearanceModeSelection gui_mac.txt /*MMAppearanceModeSelection* MMCellWidthMultiplier gui_mac.txt /*MMCellWidthMultiplier* MMCmdLineAlignBottom gui_mac.txt /*MMCmdLineAlignBottom* diff --git a/src/MacVim/Base.lproj/Preferences.xib b/src/MacVim/Base.lproj/Preferences.xib index 598673cbcc..f5e5c847fa 100644 --- a/src/MacVim/Base.lproj/Preferences.xib +++ b/src/MacVim/Base.lproj/Preferences.xib @@ -9,9 +9,11 @@ + + @@ -475,6 +477,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MacVim/MMAppController.m b/src/MacVim/MMAppController.m index eda69bf161..776e54c12a 100644 --- a/src/MacVim/MMAppController.m +++ b/src/MacVim/MMAppController.m @@ -256,6 +256,7 @@ + (void)initialize [NSNumber numberWithBool:YES], MMShareFindPboardKey, [NSNumber numberWithBool:NO], MMSmoothResizeKey, [NSNumber numberWithBool:NO], MMCmdLineAlignBottomKey, + [NSNumber numberWithBool:YES], MMAllowForceClickLookUpKey, nil]; [[NSUserDefaults standardUserDefaults] registerDefaults:dict]; diff --git a/src/MacVim/MMCoreTextView.h b/src/MacVim/MMCoreTextView.h index 4959e04ce9..5f1aab2367 100644 --- a/src/MacVim/MMCoreTextView.h +++ b/src/MacVim/MMCoreTextView.h @@ -13,8 +13,31 @@ @class MMTextViewHelper; +/// The main text view that manages drawing Vim's content using Core Text, and +/// handles input. We are using this instead of NSTextView because of the +/// custom needs in order to draw Vim's texts, as we don't have access to the +/// full contents of Vim, and works more like a smart terminal to Vim. +/// +/// Currently the rendering is done in software via Core Text, but a future +/// extension will add support for Metal rendering which probably will require +/// splitting this class up. +/// +/// Since this class implements text rendering/input using a custom view, it +/// implements NSTextInputClient, mostly for the following needs: +/// 1. Text input. This is done via insertText / doCommandBySelector. +/// 2. Input methods (e.g. for CJK). This is done via the marked text and the +/// other APIs like selectedRange/firstRectForCharacterRange/etc. +/// 3. Support native dictionary lookup (quickLookWithEvent:) when the user +/// wants to. This mostly involves implementing the attributeSubstring / +/// firstRectForCharacterRange / characterIndexForPoint APIs. +/// There is an inherent difficulty to implementing NSTextInputClient +/// 'correctly', because it assumes we have an entire text storage with +/// indexable ranges. However, we don't have full access to Vim's internal +/// storage, and we are represening the screen view instead in row-major +/// indexing, but this becomes complicated when we want to implement marked +/// texts. We the relevant parts for comments on how we hack around this. @interface MMCoreTextView : NSView < - NSTextInput + NSTextInputClient #if MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_X_VERSION_10_14 , NSFontChanging , NSMenuItemValidation @@ -122,8 +145,21 @@ // NSTextView methods // - (void)keyDown:(NSEvent *)event; -- (void)insertText:(id)string; + +// +// NSTextInputClient methods +// +- (void)insertText:(id)string replacementRange:(NSRange)replacementRange; - (void)doCommandBySelector:(SEL)selector; +- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange; +- (void)unmarkText; +- (NSRange)selectedRange; +- (NSRange)markedRange; +- (BOOL)hasMarkedText; +- (nullable NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(nullable NSRangePointer)actualRange; +- (nonnull NSArray *)validAttributesForMarkedText; +- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(nullable NSRangePointer)actualRange; +- (NSUInteger)characterIndexForPoint:(NSPoint)point; // // NSTextContainer methods diff --git a/src/MacVim/MMCoreTextView.m b/src/MacVim/MMCoreTextView.m index 018cfc965b..21475608f2 100644 --- a/src/MacVim/MMCoreTextView.m +++ b/src/MacVim/MMCoreTextView.m @@ -596,8 +596,9 @@ - (void)keyDown:(NSEvent *)event [helper keyDown:event]; } -- (void)insertText:(id)string +- (void)insertText:(id)string replacementRange:(NSRange)replacementRange { + // We are not currently replacementRange right now. [helper insertText:string]; } @@ -606,36 +607,6 @@ - (void)doCommandBySelector:(SEL)selector [helper doCommandBySelector:selector]; } -- (BOOL)hasMarkedText -{ - return [helper hasMarkedText]; -} - -- (NSRange)markedRange -{ - return [helper markedRange]; -} - -- (NSDictionary *)markedTextAttributes -{ - return [helper markedTextAttributes]; -} - -- (void)setMarkedTextAttributes:(NSDictionary *)attr -{ - [helper setMarkedTextAttributes:attr]; -} - -- (void)setMarkedText:(id)text selectedRange:(NSRange)range -{ - [helper setMarkedText:text selectedRange:range]; -} - -- (void)unmarkText -{ - [helper unmarkText]; -} - - (void)scrollWheel:(NSEvent *)event { [helper scrollWheel:event]; @@ -1283,39 +1254,422 @@ - (NSRect)rectForRow:(int)row column:(int)col numRows:(int)nr return rect; } -- (NSArray *)validAttributesForMarkedText +#pragma mark Text Input Client +#pragma region Text Input Client + +// +// Text input client implementation. +// +// Note that we are implementing this as a row-major indexed grid of the +// current display. This is not the same as Vim's internal knowledge of the +// buffers. We don't really have access to that easily because MacVim is purely +// a GUI into Vim through a multi-process model. It's theoretically possible to +// get access to it, but it increases latency and complexity, and we won't be +// able to get access to the message output. +// +// Because of this quirk, proper marked text implementation is quite difficult. +// The OS assumes a proper text strage backing, and that marked texts are a +// contiguous region in that storage (see how markedRange API returns a single +// NSRange). This is not possible for us if Vim has the marked texts wrapped +// into multiple lines while we have split windows, or just that a long marked +// text could be hidden. Because of that, we fake it by testing for +// hasMarkedText: If we have marked texts, we always tell the OS we are +// starting from 0, and the selectedRange/markedRange/etc all treat the text +// storage as having the marked text starting from 0, and +// firstRectForCharacterRange just handles that specially to make sure we still +// draw the input method's candidate list properly. Otherwise, we just treat +// the text storage as a row-major grid of the currently displayed text, which +// works fine for dictionary lookups. +// +// Also, note that whenever the OS API uses a character index or range, it +// always refers to the unicode length, so the calculation between row/col and +// character index/range needs to go through each character and calculate its +// length. We could optimize it to cache each row's total char length if we +// want if this is an issue. +// + +/// Takes a point and convert it into a single index into the entire window's +/// text. The text is converted into a row-major format, and the lines are +/// concatenated together without injecting any spaces or newlines. Note that +/// this doesn't take into account of Vim's own window splits and whatnot for +/// now so a wrapped text in Vim would not be returned as contiguous. +/// +/// The concatenation is done without injecting newlines for simplicity and to +/// allow wrapped lines to come together but that could be changed if it's +/// undesired. +static NSUInteger utfCharIndexFromRowCol(const Grid* grid, int row, int col) +{ + // Find the raw index for the character. Note that this is not good enough. With localized / wide texts, + // some character will be single-width but have length > 1, and some character will be double-width. We + // don't pre-calculate these information (since this is needed infrequently), and so we have to search + // from first character onwards and accumulating the lengths. + // See attributedSubstringForProposedRange which also does the same thing. + const int rawIndex = row * grid->cols + col; + const int gridSize = grid->cols * grid->rows; + + NSUInteger utfIndex = 0; + for (int i = 0; i < gridSize && i < rawIndex; i++) { + NSString *str = grid->cells[i].string; + utfIndex += str == nil ? 1 : str.length; // Note: nil string means empty space. + + if (grid->cells[i].textFlags & DRAW_WIDE) { + i += 1; + } + } + return utfIndex; +} + +/// Given grid position, and a UTF-8 character offset, return the new column on +/// the same line. This doesn't support multi-line for now as there is no need +/// to. +/// +/// @param utfIndexOffset The character offset from the row/col provided. Can +/// be positive or negative. +/// +/// @return The column at the specified offset. Note that this clamps at +/// [0,cols-1] since we are only looking for the same line. +static int colFromUtfOffset(const Grid* grid, int row, int col, NSInteger utfIndexOffset) { - return nil; + if (row < 0 || col < 0 || row >= grid->rows || col >= grid->cols) { + // Should not happen + return 0; + } + if (utfIndexOffset == 0) + return col; + + const int advance = utfIndexOffset > 0 ? 1 : -1; + NSUInteger accUtfIndexOffset = 0; + + int c; + for (c = col; c > 0 && c < grid->cols - 1 && accUtfIndexOffset < labs(utfIndexOffset); c += advance) { + int rawIndex = row * grid->cols + c; + + if (advance < 0) { + // If going backwards, we have to use the last character's length + // instead, including walking back 2 chars if it happens to be a + // wide char. + rawIndex -= 1; + if (c - 2 >= 0 && grid->cells[rawIndex - 1].textFlags & DRAW_WIDE) { + c += advance; + rawIndex -= 1; + } + } + + NSString *str = grid->cells[rawIndex].string; + accUtfIndexOffset += str == nil ? 1 : str.length; // Note: nil string means empty space. + + if (advance > 0) { + if (grid->cells[rawIndex].textFlags & DRAW_WIDE) { + c += advance; + } + } + } + + // Make sure nothing out of bounds happened due to some issue with wide-character skipping. + if (c < 0) + c = 0; + if (c >= grid->cols) + c = grid->cols - 1; + + return c; +} + +/// Given a range of UTF-8 character indices, find the row/col of the beginning +/// of the range, and the end of the range *on the same line*. This doesn't +/// support searching for the end past the first line because there's no need +/// to right now. Sort of the reverse of utfCharIndexFromRowCol. +/// +/// This assumes the text representation is a row-major representation of the +/// whole grid, with no newline/spaces to separate the lines. +/// +/// @param row Return the starting character's row. +/// @param col Return the starting character's column. +/// @param firstLineNumCols Return the number of columns to the end character's +/// on the same line. If the end char is on the next line, then this +/// will just find the last column of the line. +/// @param firstLineUtf8Len Return the length of the characters on the first +/// line, in UTF-8 length. +static void rowColFromUtfRange(const Grid* grid, NSRange range, + int *row, int *col, + int *firstLineNumCols, int *firstLineUtf8Len) +{ + int startUtfIndex = -1; + int outRow = -1; + int outCol = -1; + int outFirstLineNumCols = -1; + int outFirstLineLen = -1; + + const int gridSize = grid->cols * grid->rows; + NSUInteger utfIndex = 0; + for (int i = 0; i < gridSize; i++) { + if (utfIndex >= range.location) { + // We are now past the start of the character. + const int curRow = i / grid->cols; + const int curCol = i % grid->cols; + + if (outRow == -1) { + // Record the beginning + startUtfIndex = utfIndex; + outRow = curRow; + outCol = curCol; + } + + if (utfIndex >= range.location + range.length) { + // Record the end if we found it. + if (outFirstLineNumCols == -1) { + outFirstLineLen = utfIndex - startUtfIndex; + outFirstLineNumCols = curCol - outCol; + } + break; + } + + if (curRow > outRow) { + // We didn't find the end, but we are already at next line, so + // just clamp it to the last column from the last line. + outFirstLineLen = utfIndex - startUtfIndex; + outFirstLineNumCols = grid->cols - outCol; + break; + } + + } + + NSString *str = grid->cells[i].string; + utfIndex += str == nil ? 1 : str.length; // Note: nil string means empty space. + + if (grid->cells[i].textFlags & DRAW_WIDE) { + i += 1; + } + } + + if (outRow == -1) + { + *row = 0; + *col = 1; + *firstLineNumCols = 0; + *firstLineUtf8Len = 0; + return; + } + if (outFirstLineNumCols == -1) + { + outFirstLineLen = utfIndex - startUtfIndex; + outFirstLineNumCols = grid->cols; + } + *row = outRow; + *col = outCol; + *firstLineNumCols = outFirstLineNumCols; + *firstLineUtf8Len = outFirstLineLen; } -- (NSAttributedString *)attributedSubstringFromRange:(NSRange)range +- (nonnull NSArray *)validAttributesForMarkedText { - return nil; + // Not implementing this for now. Properly implementing this would allow things like bolded underline + // for certain texts in the marked range, etc, but we would need SetMarkedTextMsgID to support it. + return @[]; } -- (NSUInteger)characterIndexForPoint:(NSPoint)point +/// Returns an attributed string containing the proposed range. This method is +/// usually called for two reasons: +/// 1. Input methods. It's unclear why the OS calls this during marked text +/// operation and returning nil doesn't seem to have any negative effect. +/// However, for operations like Hangul->Hanja (by pressing Option-Return), +/// it does rely on this after inserting the original Hangul text. +/// 2. Dictionary lookup. This is used for retrieving the formatted text that +/// the OS uses to look up and to show within the yellow box. +- (nullable NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(nullable NSRangePointer)actualRange; { - return NSNotFound; + // Because of Unicode / wide characters, we have to unfortunately loop through the entire text to + // find the range. We could add better accelerated data structure here if for some reason this is + // slow (it should only be called when inputting special / localized characters or when doing + // quickLook lookup (e.g. Cmd-Ctrl-D). This step is important though or emojis and say Chinese + // characters would not behave properly. See characterIndexForPoint which also does the same thing. + + if (range.length == 0) { + return nil; + } + + if ([helper hasMarkedText]) { + // Since marked text changes the meaning of the text storage ranges (see above overall design), + // just don't return anything for now. We could simply return the marked text if we want to and + // have a need to do so. + return nil; + } + + NSMutableString *retStr = nil; + NSUInteger utfIndex = 0; + + const int gridSize = grid.cols * grid.rows; + for (int i = 0; i < gridSize; i++) { + NSString *str = grid.cells[i].string; + if (str == nil) { + str = @" "; + } + + if (utfIndex >= range.location) { + if (retStr == nil) { + // Lazily initialize the return string in case the passed in range is just completely + // out of bounds. + retStr = [NSMutableString stringWithCapacity:range.length];; + } + [retStr appendString:str]; + } + if (retStr.length >= range.length) { + break; + } + + // Increment counters + utfIndex += str.length; + if (grid.cells[i].textFlags & DRAW_WIDE) { + i += 1; + } + } + + if (retStr == nil) { + return nil; + } + if (actualRange != NULL) { + actualRange->length = retStr.length; + } + // Return an attributed string with the correct font so it will long right. + // Note that this won't get us a perfect replica of the displayed texts, + // but good enough. Some reasons why it's not perfect: + // - Asian characters don't get displayed in double-width under OS + // rendering and will be narrower. + // - We aren't passing through bold/italics/underline/strike-through/etc + // for now. This is probably ok. If we want to tackle this maybe just + // bold/underline is enough. Even NSTextView doesn't pass the + // underline/etc styles over, presumably because they make reading it + // hard. + // - Font substitutions aren't handled the same way. + return [[[NSAttributedString alloc] initWithString:retStr + attributes:@{NSFontAttributeName: font} + ] autorelease]; } -- (NSInteger)conversationIdentifier +- (BOOL)hasMarkedText { - return (NSInteger)self; + return [helper hasMarkedText]; } -- (NSRange)selectedRange +- (NSRange)markedRange { - return [helper imRange]; + // This will return the range marked from 0 to size of marked text. See the + // overall text input client implementation above for more description of + // the design choice of handling marked text in this API. + return [helper markedRange]; } -- (NSRect)firstRectForCharacterRange:(NSRange)range +- (NSDictionary *)markedTextAttributes { - return [helper firstRectForCharacterRange:range]; + return [helper markedTextAttributes]; } -@end // MMCoreTextView +- (void)setMarkedTextAttributes:(NSDictionary *)attr +{ + [helper setMarkedTextAttributes:attr]; +} + +- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange; +{ + // We are not using replacementRange right now + [helper setMarkedText:string selectedRange:selectedRange]; +} + +- (void)unmarkText +{ + [helper unmarkText]; +} + +/// Returns a character index to the overall text storage. +/// +/// This is used mostly for quickLookWithEvent: calls for the OS to be able to +/// understand the textual content of this text input client. +- (NSUInteger)characterIndexForPoint:(NSPoint)point +{ + // Not using convertPointFromScreen because it's 10.12+ only. + NSRect screenRect = {point, NSZeroSize}; + NSPoint windowPt = [[self window] convertRectFromScreen:screenRect].origin; + NSPoint viewPt = [self convertPoint:windowPt fromView:nil]; + int row, col; + if (![self convertPoint:viewPt toRow:&row column:&col]) { + return NSNotFound; + } + + return utfCharIndexFromRowCol(&grid, row, col); +} +- (NSRange)selectedRange +{ + if ([helper hasMarkedText]) { + // This returns the current cursor position relative to the marked + // range, starting from 0. See above overall comments on text input + // client implementation for marked text API decision. + return [helper imRange]; + } + + // Find the character index. + int row = [helper preEditRow]; + int col = [helper preEditColumn]; + NSUInteger charIndex = utfCharIndexFromRowCol(&grid, row, col); + + // We don't support selected texts for now, so always return length = 0; + NSRange result = {charIndex, 0}; + return result; +} +/// Return the first line's rectangle for a range of characters. This is +/// usually called either during marked text operation to decide where to show +/// a candidate list, or when doing dictionary lookup and the UI wants to draw +/// a box right on top of this text seamlessly. +/// +/// @param range The range to show rect for. Note that during marked text +/// operation, this could be different from imRange. For example, when using +/// Japanese input to input a long line of text, the user could use +/// left/right arrow keys to jump to different section of the +/// in-progress phrase and pick a new candidate. When doing that, this +/// will get called with different range's in order to show the +/// candidate list box right below the current section under +/// consideration. +/// @param actualRange The actual range this rect represents. Only used for +/// non-marked text situations for now. +- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(nullable NSRangePointer)actualRange +{ + if ([helper hasMarkedText]) { + // Marked texts have special handling (see above overall comments for + // marked text API design). + + // Because we just expose the range as 0 to marked length, the range + // here doesn't represent the final screen position. Instead, we use + // the current cursor position as basis. We know that during marked + // text operations it has to be inside the marked range as specified by + // setMarkedText's range. + const int cursorRow = [helper preEditRow]; + const int cursorCol = [helper preEditColumn]; + + // Now, we retrieve the IM range that setMarkedText gave us, and + // compare with what the OS wants now (range). Find the rectangle + // surrounding that. + const NSRange imRange = [helper imRange]; + const NSInteger startIndexOffset = range.location - imRange.location; + const NSInteger endIndexOffset = range.location + range.length - imRange.location; + + const int rectBeginCol = colFromUtfOffset(&grid, cursorRow, cursorCol, startIndexOffset); + const int rectEndCol = colFromUtfOffset(&grid, cursorRow, cursorCol, endIndexOffset); + + return [helper firstRectForCharacterRange:cursorRow column:rectBeginCol length:(rectEndCol - rectBeginCol)]; + } else { + int row = 0, col = 0, firstLineNumCols = 0, firstLineUtf8Len = 0; + rowColFromUtfRange(&grid, range, &row, &col, &firstLineNumCols, &firstLineUtf8Len); + if (actualRange != NULL) { + actualRange->location = range.location; + actualRange->length = firstLineUtf8Len; + } + return [helper firstRectForCharacterRange:row column:col length:firstLineNumCols]; + } +} + +#pragma endregion // Text Input Client + +@end // MMCoreTextView @implementation MMCoreTextView (Private) diff --git a/src/MacVim/MMPreferenceController.h b/src/MacVim/MMPreferenceController.h index 4eed8df52a..9edaf9631b 100644 --- a/src/MacVim/MMPreferenceController.h +++ b/src/MacVim/MMPreferenceController.h @@ -14,12 +14,16 @@ @interface MMPreferenceController : DBPrefsWindowController { IBOutlet NSView *generalPreferences; IBOutlet NSView *appearancePreferences; + IBOutlet NSView *inputPreferences; IBOutlet NSView *advancedPreferences; // General pane IBOutlet NSPopUpButton *layoutPopUpButton; IBOutlet NSButton *autoInstallUpdateButton; IBOutlet NSView *sparkleUpdaterPane; + + // Input pane + IBOutlet NSButton *allowForceClickLookUpButton; } // General pane diff --git a/src/MacVim/MMPreferenceController.m b/src/MacVim/MMPreferenceController.m index 2628ffc72f..42dec49653 100644 --- a/src/MacVim/MMPreferenceController.m +++ b/src/MacVim/MMPreferenceController.m @@ -43,6 +43,17 @@ - (IBAction)showWindow:(id)sender { [super setCrossFade:NO]; [super showWindow:sender]; + + // Refresh enabled states for settings that may or may not make sense + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + if (allowForceClickLookUpButton != nil) { + // Only enable force click lookup setting if only the user has configured so to begin with. + // Otherwise it doesn't make sense at all. + // Note: This cannot be done in simple bindings, because NSUserDefaults don't really support + // global domain bindings from what I can tell, we have to manually read it. + const BOOL useForceClickLookup = [ud boolForKey:@"com.apple.trackpad.forceClick"]; + [allowForceClickLookUpButton setEnabled:useForceClickLookup]; + } } - (void)setupToolbar @@ -58,6 +69,10 @@ - (void)setupToolbar label:@"Appearance" image:[NSImage imageWithSystemSymbolName:@"paintbrush" accessibilityDescription:nil]]; + [self addView:inputPreferences + label:@"Input" + image:[NSImage imageWithSystemSymbolName:@"keyboard" accessibilityDescription:nil]]; + [self addView:advancedPreferences label:@"Advanced" image:[NSImage imageWithSystemSymbolName:@"gearshape.2" accessibilityDescription:nil]]; @@ -73,6 +88,10 @@ - (void)setupToolbar label:@"Appearance" image:[NSImage imageNamed:NSImageNameColorPanel]]; + [self addView:inputPreferences + label:@"Input" + image:[NSImage imageNamed:NSImageNamePreferencesGeneral]]; // not a good choice but works for now + [self addView:advancedPreferences label:@"Advanced" image:[NSImage imageNamed:NSImageNameAdvanced]]; diff --git a/src/MacVim/MMTextViewHelper.h b/src/MacVim/MMTextViewHelper.h index f519ab8f4f..72518b40e7 100644 --- a/src/MacVim/MMTextViewHelper.h +++ b/src/MacVim/MMTextViewHelper.h @@ -44,8 +44,8 @@ NSRange markedRange; NSDictionary *markedTextAttributes; NSMutableAttributedString *markedText; - int preEditRow; - int preEditColumn; + int preEditRow; ///< The cursor's row. Note that this gets set no matter what. Doesn't matter if we are in pre-edit or not. + int preEditColumn; ///< The cursor's column. BOOL imControl; BOOL imState; TISInputSourceRef lastImSource; @@ -90,6 +90,7 @@ - (NSRange)imRange; - (void)setMarkedRange:(NSRange)range; - (NSRect)firstRectForCharacterRange:(NSRange)range; +- (NSRect)firstRectForCharacterRange:(int)row column:(int)col length:(int)length; - (void)setImControl:(BOOL)enable; - (void)activateIm:(BOOL)enable; - (BOOL)useInlineIm; diff --git a/src/MacVim/MMTextViewHelper.m b/src/MacVim/MMTextViewHelper.m index f178db5b16..7db06b0bc4 100644 --- a/src/MacVim/MMTextViewHelper.m +++ b/src/MacVim/MMTextViewHelper.m @@ -503,8 +503,29 @@ - (void)pressureChangeWithEvent:(NSEvent *)event if (event.stage >= 2) { if (!inForceClick) { inForceClick = YES; - - [self sendGestureEvent:MMGestureForceClick flags:[event modifierFlags]]; + + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + + // See if the OS is configured to use Force click for data lookups + // (the other option usually being three-finger tap). + const BOOL useForceClickLookup = [ud boolForKey:@"com.apple.trackpad.forceClick"]; + + // See if the user has overriden to disallow Force click lookups. + // The usual reason for disallowing it is to support binding + // mappings in Vim. + const BOOL userAllowsForceClickLookup = [ud boolForKey:MMAllowForceClickLookUpKey]; + + if (useForceClickLookup && userAllowsForceClickLookup) { + // For some odd reason, we don't get quickLookWithEvent: even when + // the user has configured to use force click instead of 3-finger + // tap. We need to manually invoke it (this is how NSTextView does + // it as well). References for other software that do this: + // https://gitlab.com/gnachman/iterm2/-/blob/master/sources/PointerController.m + // https://searchfox.org/mozilla-central/source/widget/cocoa/nsChildView.mm + [textView quickLookWithEvent:event]; + } else { + [self sendGestureEvent:MMGestureForceClick flags:[event modifierFlags]]; + } } } else { inForceClick = NO; @@ -751,8 +772,23 @@ - (void)setMarkedRange:(NSRange)range markedRange = range; } +/// Don't use this. See comments. - (NSRect)firstRectForCharacterRange:(NSRange)range { + // + // Note: This is really quite a buggy method and relies on improper + // assumptions. It's kept alive for now because MMTextView (which is also + // deprecated and shouldn't be used for real users) uses this. + // Known bugs: + // - Assumes that preEditRow/Column is the beginning of the marked range, + // but the way it actually works is that it's the current cursor *within* + // the marked range. + // - Uses fontWide to decide to jump 1 or 2 columns per character. First, + // this is wrong, as wide texts work just fine even without guifontwide + // set. Second, some characters may have length > 1. See MMCoreTextView + // which does proper length calculation. + // + // This method is called when the input manager wants to pop up an // auxiliary window. The position where this should be is controlled by // Vim by sending SetPreEditPositionMsgID so compute a position based on @@ -761,13 +797,13 @@ - (NSRect)firstRectForCharacterRange:(NSRange)range int row = preEditRow; NSFont *theFont = [[textView markedTextAttributes] - valueForKey:NSFontAttributeName]; + valueForKey:NSFontAttributeName]; if (theFont == [textView fontWide]) { col += imRange.location * 2; if (col >= [textView maxColumns] - 1) { row += (col / [textView maxColumns]); col = col % 2 ? col % [textView maxColumns] + 1 : - col % [textView maxColumns]; + col % [textView maxColumns]; } } else { col += imRange.location; @@ -777,10 +813,15 @@ - (NSRect)firstRectForCharacterRange:(NSRange)range } } + return [self firstRectForCharacterRange:row column:col length:range.length]; +} + +- (NSRect)firstRectForCharacterRange:(int)row column:(int)col length:(int)numColumns +{ NSRect rect = [textView rectForRow:row column:col numRows:1 - numColumns:range.length]; + numColumns:numColumns]; // NOTE: If the text view is flipped then 'rect' has its origin in the top // left corner of the rect, but the methods below expect it to be in the diff --git a/src/MacVim/Miscellaneous.h b/src/MacVim/Miscellaneous.h index b9a1816d09..0000a26f54 100644 --- a/src/MacVim/Miscellaneous.h +++ b/src/MacVim/Miscellaneous.h @@ -61,6 +61,7 @@ extern NSString *MMNonNativeFullScreenShowMenuKey; extern NSString *MMNonNativeFullScreenSafeAreaBehaviorKey; extern NSString *MMSmoothResizeKey; extern NSString *MMCmdLineAlignBottomKey; +extern NSString *MMAllowForceClickLookUpKey; // Enum for MMUntitledWindowKey diff --git a/src/MacVim/Miscellaneous.m b/src/MacVim/Miscellaneous.m index 2b1bddee5e..0fdf96cd92 100644 --- a/src/MacVim/Miscellaneous.m +++ b/src/MacVim/Miscellaneous.m @@ -57,6 +57,7 @@ NSString *MMNonNativeFullScreenSafeAreaBehaviorKey = @"MMNonNativeFullScreenSafeAreaBehavior"; NSString *MMSmoothResizeKey = @"MMSmoothResize"; NSString *MMCmdLineAlignBottomKey = @"MMCmdLineAlignBottom"; +NSString *MMAllowForceClickLookUpKey = @"MMAllowForceClickLookUp"; @implementation NSIndexSet (MMExtras)