From b589c849fbb2d2701cdc69545cebd6fd94bb5113 Mon Sep 17 00:00:00 2001 From: Yee Cheng Chin Date: Mon, 27 Jan 2025 04:05:24 -0800 Subject: [PATCH] Make GUI tabs track Vim tabs in updates and animate correctly MMTabline was introduced in #1120, which replaced the ancient PSMTabBarControl for representing Vim tabs. It uses animation to handle tab layouts, but it only worked in some situations, due to the Vim IPC API only sending a tabline update with all the tab labels with no way to track individual tabs. Update the API so that Vim now sends individual unique IDs in the update message as well to allow the GUI to track tabs over time and animate them. Vim does not interally have a concept of unique tab IDs, but we can use the pointer to the structure as such because they are allocated as a linked list and will never change. Extend MMTabline to have a new `updateTabsByTags` API to batch update all tabs in one go, which will diff the new tags with existing tabs and create/remove/move tabs as necessary. The scrolling logic has also been moved inside it and it now only scrolls to selected tab when it has changed or moved. When deleting tabs we won't scroll as usually the user doesn't expect it. Dragging tabs also now work correctly when it is removed during a drag, or another tab has been selected. This does mean the add/close tab APIs are currently unused, as the only entrypoint for modifying tabs from MacVim is currently only via `updateTabsByTags`. After this, different Vim operations will now animate correctly, including `:tabmove`, `:bdelete` (which could remove multiple tabs at once), `:tabnew`, `:tabclose`. --- src/MacVim/MMBackend.m | 36 +++--- src/MacVim/MMTabline/MMTab.h | 1 + src/MacVim/MMTabline/MMTab.m | 2 + src/MacVim/MMTabline/MMTabline.h | 24 +++- src/MacVim/MMTabline/MMTabline.m | 209 +++++++++++++++++++++++++++---- src/MacVim/MMVimView.h | 5 +- src/MacVim/MMVimView.m | 157 ++++++++++------------- src/MacVim/MMWindowController.h | 1 - src/MacVim/MMWindowController.m | 8 -- 9 files changed, 294 insertions(+), 149 deletions(-) diff --git a/src/MacVim/MMBackend.m b/src/MacVim/MMBackend.m index 569d4c7c98..9f38769967 100644 --- a/src/MacVim/MMBackend.m +++ b/src/MacVim/MMBackend.m @@ -764,32 +764,36 @@ - (void)selectTab:(int)index - (void)updateTabBar { + // Update the tab bar with the most up-to-date info, including number of + // tabs and titles/tooltips. MacVim would also like to know which specific + // tabs were moved/added/deleted in order for animation to work, but Vim + // does not have specific callbacks to listen to that. Instead, since the + // tabpage_T memory address is constant per tab, we use that as a permanent + // identifier for each GUI tab so MacVim can do the association. NSMutableData *data = [NSMutableData data]; + // 1. Current selected tab index int idx = tabpage_index(curtab) - 1; [data appendBytes:&idx length:sizeof(int)]; tabpage_T *tp; + // 2. Unique id for all the tabs + // Do these first so they appear as a consecutive memory block. for (tp = first_tabpage; tp != NULL; tp = tp->tp_next) { - // Count the number of windows in the tabpage. - //win_T *wp = tp->tp_firstwin; - //int wincount; - //for (wincount = 0; wp != NULL; wp = wp->w_next, ++wincount); - //[data appendBytes:&wincount length:sizeof(int)]; - - int tabProp = MMTabInfoCount; - [data appendBytes:&tabProp length:sizeof(int)]; - for (tabProp = MMTabLabel; tabProp < MMTabInfoCount; ++tabProp) { + [data appendBytes:&tp length:sizeof(void*)]; + } + // Null terminate the unique IDs. + tp = 0; + [data appendBytes:&tp length:sizeof(void*)]; + // 3. Labels and tooltips of each tab + for (tp = first_tabpage; tp != NULL; tp = tp->tp_next) { + for (int tabProp = MMTabLabel; tabProp < MMTabInfoCount; ++tabProp) { // This function puts the label of the tab in the global 'NameBuff'. get_tabline_label(tp, (tabProp == MMTabToolTip)); - NSString *s = [NSString stringWithVimString:NameBuff]; - int len = [s lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; - if (len < 0) - len = 0; - - [data appendBytes:&len length:sizeof(int)]; + size_t len = STRLEN(NameBuff); + [data appendBytes:&len length:sizeof(size_t)]; if (len > 0) - [data appendBytes:[s UTF8String] length:len]; + [data appendBytes:NameBuff length:len]; } } diff --git a/src/MacVim/MMTabline/MMTab.h b/src/MacVim/MMTabline/MMTab.h index 2bd05037d2..8a6c198a83 100644 --- a/src/MacVim/MMTabline/MMTab.h +++ b/src/MacVim/MMTabline/MMTab.h @@ -14,6 +14,7 @@ typedef enum : NSInteger { @interface MMTab : NSView +@property (nonatomic, readwrite) NSInteger tag; ///< Unique identifier that caller can set for the tab @property (nonatomic, copy) NSString *title; @property (nonatomic, getter=isCloseButtonHidden) BOOL closeButtonHidden; @property (nonatomic) MMTabState state; diff --git a/src/MacVim/MMTabline/MMTab.m b/src/MacVim/MMTabline/MMTab.m index e69b1335f2..5b3fe16ce4 100644 --- a/src/MacVim/MMTabline/MMTab.m +++ b/src/MacVim/MMTabline/MMTab.m @@ -20,6 +20,8 @@ @implementation MMTab NSTextField *_titleLabel; } +@synthesize tag = _tag; + + (id)defaultAnimationForKey:(NSAnimatablePropertyKey)key { if ([key isEqualToString:@"fillColor"]) { diff --git a/src/MacVim/MMTabline/MMTabline.h b/src/MacVim/MMTabline/MMTabline.h index 18c7dca70e..462619d2f0 100644 --- a/src/MacVim/MMTabline/MMTabline.h +++ b/src/MacVim/MMTabline/MMTabline.h @@ -10,7 +10,8 @@ @interface MMTabline : NSView -@property (nonatomic) NSInteger selectedTabIndex; +/// The index of the selected tab. Can be -1 if nothing is selected. +@property (nonatomic, readonly) NSInteger selectedTabIndex; @property (nonatomic) NSInteger optimumTabWidth; @property (nonatomic) NSInteger minimumTabWidth; @property (nonatomic) BOOL showsAddTabButton; @@ -24,10 +25,31 @@ @property (nonatomic, retain) NSColor *tablineFillFgColor; @property (nonatomic, weak) id delegate; +/// Add a tab at the end. It's not selected automatically. - (NSInteger)addTabAtEnd; +/// Add a tab after the selected one. It's not selected automatically. - (NSInteger)addTabAfterSelectedTab; +/// Add a tab at specified index. It's not selected automatically. - (NSInteger)addTabAtIndex:(NSInteger)index; + - (void)closeTab:(MMTab *)tab force:(BOOL)force layoutImmediately:(BOOL)layoutImmediately; + +/// Batch update all the tabs using tab tags as unique IDs. Tab line will handle +/// creating / removing tabs as necessary, and moving tabs to their new +/// positions. +/// +/// The tags array has to have unique items only, and each existing MMTab also +/// has to have unique tags. +/// +/// @param tags The list of unique tags that are cross-referenced with each +/// MMTab's tag. Order within the array indicates the desired tab order. +/// @param len The length of the tags array. +/// @param delayTabResize If true, do not resize tab widths until the the tab +/// line loses focus. This helps preserve the relative tab positions and +/// lines up the close buttons to the previous tab. This will also +/// prevent scrolling to the new selected tab. +- (void)updateTabsByTags:(NSInteger *)tags len:(NSUInteger)len delayTabResize:(BOOL)delayTabResize; + - (void)selectTabAtIndex:(NSInteger)index; - (MMTab *)tabAtIndex:(NSInteger)index; - (void)scrollTabToVisibleAtIndex:(NSInteger)index; diff --git a/src/MacVim/MMTabline/MMTabline.m b/src/MacVim/MMTabline/MMTabline.m index 5d0d04c7ba..c9e037a85f 100644 --- a/src/MacVim/MMTabline/MMTabline.m +++ b/src/MacVim/MMTabline/MMTabline.m @@ -39,7 +39,6 @@ @implementation MMTabline NSLayoutConstraint *_tabScrollButtonsLeadingConstraint; NSLayoutConstraint *_addTabButtonTrailingConstraint; BOOL _pendingFixupLayout; - MMTab *_selectedTab; MMTab *_draggedTab; CGFloat _xOffsetForDrag; NSInteger _initialDraggedTabIndex; @@ -64,7 +63,11 @@ - (instancetype)initWithFrame:(NSRect)frameRect _tabs = [NSMutableArray new]; _showsAddTabButton = YES; // get from NSUserDefaults _showsTabScrollButtons = YES; // get from NSUserDefaults - + + _selectedTabIndex = -1; + + _initialDraggedTabIndex = _finalDraggedTabIndex = NSNotFound; + // This view holds the tab views. _tabsContainer = [NSView new]; _tabsContainer.frame = (NSRect){{0, 0}, frameRect.size}; @@ -269,7 +272,7 @@ - (NSInteger)addTabAtIndex:(NSInteger)index TabWidth t = [self tabWidthForTabs:_tabs.count + 1]; NSRect frame = _tabsContainer.bounds; frame.size.width = index == _tabs.count ? t.width + t.remainder : t.width; - frame.origin.x = index ? index * (t.width - TabOverlap) : 0; + frame.origin.x = index * (t.width - TabOverlap); MMTab *newTab = [[MMTab alloc] initWithFrame:frame tabline:self]; [_tabs insertObject:newTab atIndex:index]; @@ -292,14 +295,7 @@ - (void)closeTab:(MMTab *)tab force:(BOOL)force layoutImmediately:(BOOL)layoutIm if (![self.delegate tabline:self shouldCloseTabAtIndex:index]) return; } if (index != NSNotFound) { - CGFloat w = NSWidth(tab.frame) - TabOverlap; [tab removeFromSuperview]; - for (NSInteger i = index + 1; i < _tabs.count; i++) { - MMTab *tv = _tabs[i]; - NSRect frame = tv.frame; - frame.origin.x -= w; - tv.animator.frame = frame; - } [_tabs removeObject:tab]; if (index <= _selectedTabIndex) { if (index < _selectedTabIndex || index > _tabs.count - 1) { @@ -311,26 +307,162 @@ - (void)closeTab:(MMTab *)tab force:(BOOL)force layoutImmediately:(BOOL)layoutIm } [self fixupCloseButtons]; [self evaluateHoverStateForMouse:[self.window mouseLocationOutsideOfEventStream]]; - if (layoutImmediately) [self fixupLayoutWithAnimation:YES]; - else _pendingFixupLayout = YES; + [self fixupLayoutWithAnimation:YES delayResize:!layoutImmediately]; } else { NSLog(@"CANNOT FIND TAB TO REMOVE"); } } +- (void)updateTabsByTags:(NSInteger *)tags len:(NSUInteger)len delayTabResize:(BOOL)delayTabResize +{ + BOOL needUpdate = NO; + if (len != _tabs.count) { + needUpdate = YES; + } else { + for (NSUInteger i = 0; i < len; i++) { + MMTab *tab = _tabs[i]; + if (tab.tag != tags[i]) { + needUpdate = YES; + break; + } + } + } + if (!needUpdate) + return; + + // Perform a diff between the existing tabs (using MMTab's tags as unique + // identifiers) and the input specified tags + + // Create a mapping for tags->index. Could potentially cache this but it's + // simpler to recreate this every time to avoid tracking states. + NSMutableDictionary *tagToTabIdx = [NSMutableDictionary dictionaryWithCapacity:_tabs.count]; + for (NSUInteger i = 0; i < _tabs.count; i++) { + MMTab *tab = _tabs[i]; + if (tagToTabIdx[@(tab.tag)] != nil) { + NSLog(@"Duplicate tag found in tabs"); + // Duplicates are not supposed to exist. We need to remove the view + // here because the algorithm below will not handle this case and + // leaves stale views. + [tab removeFromSuperview]; + continue; + } + tagToTabIdx[@(tab.tag)] = @(i); + } + + const NSInteger oldSelectedTabTag = _selectedTabIndex < 0 ? 0 : _tabs[_selectedTabIndex].tag; + NSInteger newSelectedTabIndex = -1; + + // Allocate a new tabs list and store all the new and moved tabs there. This + // is simpler than an in-place algorithm. + NSMutableArray *newTabs = [NSMutableArray arrayWithCapacity:len]; + for (NSUInteger i = 0; i < len; i++) { + NSInteger tag = tags[i]; + NSNumber *newTabIdxObj = [tagToTabIdx objectForKey:@(tag)]; + if (newTabIdxObj == nil) { + // Create new tab + NSInteger index = i; + TabWidth t = [self tabWidthForTabs:len]; + NSRect frame = _tabsContainer.bounds; + frame.size.width = i == (len - 1) ? t.width + t.remainder : t.width; + frame.origin.x = i * (t.width - TabOverlap); + MMTab *newTab = [[MMTab alloc] initWithFrame:frame tabline:self]; + newTab.tag = tag; + [newTabs insertObject:newTab atIndex:index]; + [_tabsContainer addSubview:newTab]; + } else { + // Move existing tab + NSUInteger newTabIdx = [newTabIdxObj unsignedIntegerValue]; + [newTabs addObject:_tabs[newTabIdx]]; + [tagToTabIdx removeObjectForKey:@(tag)]; + + // Remap indices if needed + if (newTabIdx == _selectedTabIndex) { + newSelectedTabIndex = newTabs.count - 1; + } + if (newTabIdx == _initialDraggedTabIndex) { + _initialDraggedTabIndex = newTabs.count - 1; + _finalDraggedTabIndex = _initialDraggedTabIndex; + } + } + } + + // Now go through the remaining tabs that did not make it to the new list + // and remove them. + NSInteger numDeletedTabsBeforeSelected = 0; + for (NSUInteger i = 0; i < _tabs.count; i++) { + MMTab *tab = _tabs[i]; + if ([tagToTabIdx objectForKey:@(tab.tag)] == nil) { + continue; + } + [tab removeFromSuperview]; + if (i < _selectedTabIndex) { + numDeletedTabsBeforeSelected++; + } + if (_draggedTab != nil && _draggedTab == tab) { + _draggedTab = nil; + _initialDraggedTabIndex = _finalDraggedTabIndex = NSNotFound; + } + } + const BOOL selectedTabMovedByDeleteOnly = newSelectedTabIndex != -1 && + (newSelectedTabIndex == _selectedTabIndex - numDeletedTabsBeforeSelected); + + _tabs = newTabs; + + if (newSelectedTabIndex == -1) { + // The old selected tab is removed. Select a new one nearby. + newSelectedTabIndex = _selectedTabIndex >= _tabs.count ? _tabs.count - 1 : _selectedTabIndex; + } + [self selectTabAtIndex:newSelectedTabIndex]; + + [self fixupLayoutWithAnimation:YES delayResize:delayTabResize]; + [self fixupCloseButtons]; + [self evaluateHoverStateForMouse:[self.window mouseLocationOutsideOfEventStream]]; + + // Heuristics for scrolling to the selected tab after update: + // 1. If 'delayTabResize' is set, we are trying to line up tab positions, do + // DON'T scroll, even if the old selected tab was removed. + // 2. Otherwise if we changed tab selection (happens when the selected tab + // was removed), just scroll to the new selected tab. + // 3. If the selected tab has moved in position, scroll to it, unless it + // only moved due to the earlier tabs being deleted (meaning that the tab + // ordering was preserved). This helps prevent unnecessary scrolling + // around when the user is trying to delete tabs in other areas. + // This chould potentially be exposed to the caller for more custimization. + const NSInteger newSelectedTabTag = _selectedTabIndex < 0 ? 0 : _tabs[_selectedTabIndex].tag; + BOOL scrollToSelected = NO; + if (!delayTabResize) { + if (oldSelectedTabTag != newSelectedTabTag) + scrollToSelected = YES; + else if (!selectedTabMovedByDeleteOnly) + scrollToSelected = YES; + } + if (scrollToSelected) + [self scrollTabToVisibleAtIndex:_selectedTabIndex]; +} + - (void)selectTabAtIndex:(NSInteger)index { - if (_selectedTabIndex <= _tabs.count - 1) { + if (_draggedTab != nil) { + // Selected a non-dragged tab, simply unset the dragging operation. This + // is somewhat Vim-specific, as it does not support re-ordering a + // non-active tab. Could be made configurable in the future. + if (index < 0 || index >= _tabs.count || _tabs[index] != _draggedTab) { + _draggedTab = nil; + _initialDraggedTabIndex = _finalDraggedTabIndex = NSNotFound; + [self fixupLayoutWithAnimation:YES]; + } + } + if (_selectedTabIndex >= 0 && _selectedTabIndex <= _tabs.count - 1) { _tabs[_selectedTabIndex].state = MMTabStateUnselected; } if (index <= _tabs.count - 1) { _selectedTabIndex = index; - _tabs[_selectedTabIndex].state = MMTabStateSelected; + if (index >= 0) + _tabs[_selectedTabIndex].state = MMTabStateSelected; } else { NSLog(@"TRIED TO SELECT OUT OF BOUNDS: %ld/%ld", index, _tabs.count - 1); } - _selectedTab = _tabs[_selectedTabIndex]; [self fixupTabZOrder]; } @@ -429,17 +561,30 @@ - (void)fixupTabZOrder [_tabsContainer sortSubviewsUsingFunction:SortTabsForZOrder context:(__bridge void *)(_draggedTab)]; } -- (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate +- (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate delayResize:(BOOL)delayResize { if (_tabs.count == 0) return; + if (delayResize) { + // The pending delayed resize is trigged by mouse exit, but if we are + // already outside, then there's nothing to delay. + NSPoint locationInWindow = [self.window mouseLocationOutsideOfEventStream]; + if (![self mouse:locationInWindow inRect:self.frame]) { + delayResize = NO; + } + } + TabWidth t = [self tabWidthForTabs:_tabs.count]; for (NSInteger i = 0; i < _tabs.count; i++) { MMTab *tab = _tabs[i]; if (_draggedTab == tab) continue; NSRect frame = tab.frame; - frame.size.width = i == _tabs.count - 1 ? t.width + t.remainder : t.width; - frame.origin.x = i ? i * (t.width - TabOverlap) : 0; + if (delayResize) { + frame.origin.x = i != 0 ? i * (NSWidth(_tabs[i-1].frame) - TabOverlap) : 0; + } else { + frame.size.width = i == _tabs.count - 1 ? t.width + t.remainder : t.width; + frame.origin.x = i != 0 ? i * (t.width - TabOverlap) : 0; + } if (shouldAnimate) { [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { context.allowsImplicitAnimation = YES; @@ -450,13 +595,22 @@ - (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate tab.frame = frame; } } - // _tabsContainer expands to fit tabs, is at least as wide as _scrollView. - NSRect frame = _tabsContainer.frame; - frame.size.width = t.width * _tabs.count - TabOverlap * (_tabs.count - 1); - frame.size.width = NSWidth(frame) < NSWidth(_scrollView.frame) ? NSWidth(_scrollView.frame) : NSWidth(frame); - if (shouldAnimate) _tabsContainer.animator.frame = frame; - else _tabsContainer.frame = frame; - [self updateTabScrollButtonsEnabledState]; + if (delayResize) { + _pendingFixupLayout = YES; + } else { + // _tabsContainer expands to fit tabs, is at least as wide as _scrollView. + NSRect frame = _tabsContainer.frame; + frame.size.width = t.width * _tabs.count - TabOverlap * (_tabs.count - 1); + frame.size.width = NSWidth(frame) < NSWidth(_scrollView.frame) ? NSWidth(_scrollView.frame) : NSWidth(frame); + if (shouldAnimate) _tabsContainer.animator.frame = frame; + else _tabsContainer.frame = frame; + [self updateTabScrollButtonsEnabledState]; + } +} + +- (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate +{ + [self fixupLayoutWithAnimation:shouldAnimate delayResize:NO]; } #pragma mark - Mouse @@ -556,6 +710,7 @@ - (void)mouseUp:(NSEvent *)event [self.delegate tabline:self didDragTab:_tabs[_finalDraggedTabIndex] toIndex:_finalDraggedTabIndex]; } } + _initialDraggedTabIndex = _finalDraggedTabIndex = NSNotFound; } - (void)mouseDragged:(NSEvent *)event @@ -569,12 +724,13 @@ - (void)mouseDragged:(NSEvent *)event [_tabsContainer autoscroll:event]; [self fixupTabZOrder]; [_draggedTab setFrameOrigin:NSMakePoint(mouse.x - _xOffsetForDrag, 0)]; + MMTab *selectedTab = _selectedTabIndex == -1 ? nil : _tabs[_selectedTabIndex]; [_tabs sortWithOptions:NSSortStable usingComparator:^NSComparisonResult(MMTab *t1, MMTab *t2) { if (NSMinX(t1.frame) <= NSMinX(t2.frame)) return NSOrderedAscending; if (NSMinX(t1.frame) > NSMinX(t2.frame)) return NSOrderedDescending; return NSOrderedSame; }]; - _selectedTabIndex = [_tabs indexOfObject:_selectedTab]; + _selectedTabIndex = _selectedTabIndex == -1 ? -1 : [_tabs indexOfObject:selectedTab]; _finalDraggedTabIndex = [_tabs indexOfObject:_draggedTab]; [self fixupLayoutWithAnimation:YES]; } @@ -604,6 +760,7 @@ - (void)updateTabScrollButtonsEnabledState - (void)scrollTabToVisibleAtIndex:(NSInteger)index { if (_tabs.count == 0) return; + if (index < 0 || index >= _tabs.count) return; // Get the amount of time elapsed between the previous invocation // of this method and now. Use this elapsed time to set the animation diff --git a/src/MacVim/MMVimView.h b/src/MacVim/MMVimView.h index eac3d4da39..8f7da31a2a 100644 --- a/src/MacVim/MMVimView.h +++ b/src/MacVim/MMVimView.h @@ -20,8 +20,9 @@ @interface MMVimView : NSView { + /// The tab that has been requested to be closed and waiting on Vim to respond + NSInteger pendingCloseTabID; MMTabline *tabline; - MMTab *tabToClose; MMVimController *vimController; MMTextView *textView; NSMutableArray *scrollbars; @@ -44,8 +45,6 @@ - (MMTabline *)tabline; - (IBAction)addNewTab:(id)sender; - (void)updateTabsWithData:(NSData *)data; -- (void)selectTabWithIndex:(int)idx; -- (MMTab *)addNewTab; - (void)createScrollbarWithIdentifier:(int32_t)ident type:(int)type; - (BOOL)destroyScrollbarWithIdentifier:(int32_t)ident; diff --git a/src/MacVim/MMVimView.m b/src/MacVim/MMVimView.m index 2a88fa9616..35a55cbb30 100644 --- a/src/MacVim/MMVimView.m +++ b/src/MacVim/MMVimView.m @@ -254,118 +254,82 @@ - (IBAction)addNewTab:(id)sender [vimController sendMessage:AddNewTabMsgID data:nil]; } +/// Callback from Vim to update the tabline with new tab data - (void)updateTabsWithData:(NSData *)data { const void *p = [data bytes]; - const void *end = p + [data length]; - int tabIdx = 0; - BOOL didCloseTab = NO; - - // Count how many tabs Vim has and compare to the number MacVim's tabline has. - const void *q = [data bytes]; - int vimNumberOfTabs = 0; - q += sizeof(int); // skip over current tab index - while (q < end) { - int infoCount = *((int*)q); q += sizeof(int); - for (unsigned i = 0; i < infoCount; ++i) { - int length = *((int*)q); q += sizeof(int); - if (length <= 0) continue; - q += length; - if (i == MMTabLabel) ++vimNumberOfTabs; - } - } - // Close the specific tab where the user clicked the close button. - if (tabToClose && vimNumberOfTabs == tabline.numberOfTabs - 1) { - [tabline closeTab:tabToClose force:YES layoutImmediately:NO]; - tabToClose = nil; - didCloseTab = YES; - } + const void * const end = p + [data length]; - // HACK! Current tab is first in the message. This way it is not - // necessary to guess which tab should be the selected one (this can be - // problematic for instance when new tabs are created). + // 1. Current tab is first in the message. int curtabIdx = *((int*)p); p += sizeof(int); + // 2. Read all the tab IDs (which uniquely identify each tab), and count + // the number of Vim tabs in the process of doing so. + int numTabs = 0; + BOOL pendingCloseTabClosed = (pendingCloseTabID != 0); + const intptr_t * const tabIDs = p; while (p < end) { - MMTab *tv; + intptr_t tabID = *((intptr_t*)p); p += sizeof(intptr_t); + if (tabID == 0) // null-terminated + break; + if (pendingCloseTabID != 0 && (NSInteger)tabID == pendingCloseTabID) { + // Vim hasn't gotten around to handling the tab close message yet, + // just wait until it has done so. + pendingCloseTabClosed = NO; + } + numTabs += 1; + } - //int wincount = *((int*)p); p += sizeof(int); - int infoCount = *((int*)p); p += sizeof(int); - unsigned i; - for (i = 0; i < infoCount; ++i) { - int length = *((int*)p); p += sizeof(int); + BOOL delayTabResize = NO; + if (pendingCloseTabClosed) { + // When the user has pressed a tab close button, only animate tab + // positions, not the widths. This allows the next tab's close button + // to line up with the last, allowing the user to close multiple tabs + // quickly. + delayTabResize = YES; + pendingCloseTabID = 0; + } + + // Ask the tabline to update all the tabs based on the tab IDs + static_assert(sizeof(NSInteger) == sizeof(intptr_t), + "Tab tag size mismatch between Vim and MacVim"); + [tabline updateTabsByTags:(NSInteger*)tabIDs + len:numTabs + delayTabResize:delayTabResize]; + + // 3. Read all the tab labels/tooltips and assign to each tab + NSInteger tabIdx = 0; + while (p < end && tabIdx < tabline.numberOfTabs) { + MMTab *tv = [tabline tabAtIndex:tabIdx]; + for (unsigned i = 0; i < MMTabInfoCount; ++i) { + size_t length = *((size_t*)p); p += sizeof(size_t); if (length <= 0) continue; - NSString *val = [[NSString alloc] initWithBytes:(void*)p length:length encoding:NSUTF8StringEncoding]; p += length; - - switch (i) { - case MMTabLabel: - // Set the label of the tab, adding a new tab when needed. - tv = tabline.numberOfTabs <= tabIdx - ? [self addNewTab] - : [tabline tabAtIndex:tabIdx]; - tv.title = val; - ++tabIdx; - break; - case MMTabToolTip: - if (tv) tv.toolTip = val; - break; - default: - ASLogWarn(@"Unknown tab info for index: %d", i); + if (i == MMTabLabel) { + tv.title = val; + } else if (i == MMTabToolTip) { + tv.toolTip = val; } - [val release]; } + tabIdx += 1; } - // Remove unused tabs from the tabline. - long i, count = tabline.numberOfTabs; - for (i = count-1; i >= tabIdx; --i) { - MMTab *tv = [tabline tabAtIndex:i]; - [tabline closeTab:tv force:YES layoutImmediately:YES]; - } - - [self selectTabWithIndex:curtabIdx]; - // It would be better if we could scroll to the selected tab only if it - // reflected user intent. Presumably, the user expects MacVim to scroll - // to the selected tab if they: added a tab, clicked a partially hidden - // tab, or navigated to a tab with a keyboard command. Since we don't - // have this kind of information, we always scroll to selected unless - // the window isn't key or we think the user is in the process of - // closing a tab by clicking its close button. Doing it this way instead - // of using a signal of explicit user intent is probably too aggressive. - if (self.window.isKeyWindow && !tabToClose && !didCloseTab) { - [tabline scrollTabToVisibleAtIndex:curtabIdx]; - } -} - -- (void)selectTabWithIndex:(int)idx -{ - if (idx < 0 || idx >= tabline.numberOfTabs) { - ASLogWarn(@"No tab with index %d exists.", idx); + // Finally, select the currently selected tab + if (curtabIdx < 0 || curtabIdx >= tabline.numberOfTabs) { + ASLogWarn(@"No tab with index %d exists.", curtabIdx); return; } - // Do not try to select a tab if already selected. - if (idx != tabline.selectedTabIndex) { - [tabline selectTabAtIndex:idx]; - // We might need to change the scrollbars that are visible. - self.pendingPlaceScrollbars = YES; + if (curtabIdx != tabline.selectedTabIndex) { + [tabline selectTabAtIndex:curtabIdx]; + [tabline scrollTabToVisibleAtIndex:curtabIdx]; } } -- (MMTab *)addNewTab -{ - // NOTE! A newly created tab is not by selected by default; Vim decides - // which tab should be selected at all times. However, the AppKit will - // automatically select the first tab added to a tab view. - NSUInteger index = [tabline addTabAtEnd]; - return [tabline tabAtIndex:index]; -} - - (void)createScrollbarWithIdentifier:(int32_t)ident type:(int)type { MMScroller *scroller = [[MMScroller alloc] initWithIdentifier:ident @@ -486,7 +450,7 @@ - (BOOL)tabline:(MMTabline *)tabline shouldSelectTabAtIndex:(NSUInteger)index { // Propagate the selection message to Vim. if (NSNotFound != index) { - int i = (int)index; // HACK! Never more than MAXINT tabs?! + int i = (int)index; NSData *data = [NSData dataWithBytes:&i length:sizeof(int)]; [vimController sendMessage:SelectTabMsgID data:data]; } @@ -497,14 +461,19 @@ - (BOOL)tabline:(MMTabline *)tabline shouldSelectTabAtIndex:(NSUInteger)index - (BOOL)tabline:(MMTabline *)tabline shouldCloseTabAtIndex:(NSUInteger)index { if (index >= 0 && index < tabline.numberOfTabs - 1) { - tabToClose = [tabline tabAtIndex:index]; + // If the user is closing any tab other than the last one, we remember + // the state so later on we don't resize the tabs in the layout + // animation to preserve the stability of tab positions to allow for + // quickly closing multiple tabs. This is similar to how macOS tabs + // work. + pendingCloseTabID = [tabline tabAtIndex:index].tag; } - // HACK! This method is only called when the user clicks the close button - // on the tab. Instead of letting the tab bar close the tab, we return NO - // and pass a message on to Vim to let it handle the closing. - int i = (int)index; // HACK! Never more than MAXINT tabs?! + // Propagate the close message to Vim + int i = (int)index; NSData *data = [NSData dataWithBytes:&i length:sizeof(int)]; [vimController sendMessage:CloseTabMsgID data:data]; + + // Let Vim decide whether to close the tab or not. return NO; } diff --git a/src/MacVim/MMWindowController.h b/src/MacVim/MMWindowController.h index 8d4605abe8..df932f0e98 100644 --- a/src/MacVim/MMWindowController.h +++ b/src/MacVim/MMWindowController.h @@ -65,7 +65,6 @@ - (BOOL)presentWindow:(id)unused; - (void)moveWindowAcrossScreens:(NSPoint)origin; - (void)updateTabsWithData:(NSData *)data; -- (void)selectTabWithIndex:(int)idx; - (void)setTextDimensionsWithRows:(int)rows columns:(int)cols isLive:(BOOL)live keepGUISize:(BOOL)keepGUISize keepOnScreen:(BOOL)onScreen; diff --git a/src/MacVim/MMWindowController.m b/src/MacVim/MMWindowController.m index e52896ee85..5ae435bf34 100644 --- a/src/MacVim/MMWindowController.m +++ b/src/MacVim/MMWindowController.m @@ -331,9 +331,6 @@ - (void)openWindow // TODO: Remove this method? Everything can probably be done in // presentWindow: but must carefully check dependencies on 'setupDone' // flag. - - [vimView addNewTab]; - setupDone = YES; } @@ -392,11 +389,6 @@ - (void)updateTabsWithData:(NSData *)data [vimView updateTabsWithData:data]; } -- (void)selectTabWithIndex:(int)idx -{ - [vimView selectTabWithIndex:idx]; -} - - (void)setTextDimensionsWithRows:(int)rows columns:(int)cols isLive:(BOOL)live keepGUISize:(BOOL)keepGUISize keepOnScreen:(BOOL)onScreen