From 3f8e40538a4d42d4695025517732f4ddefde23cf Mon Sep 17 00:00:00 2001 From: Yee Cheng Chin Date: Wed, 29 Jan 2025 17:14:36 -0800 Subject: [PATCH] MMTabline: Add right-to-left (RTL) locale support In RTL locales (e.g. Arabic, Hebrew), macOS lays everything out in the flipped direction, including most UI elements and native tabs. This change makes sure MacVim tabs will obey the same convention and behave intuitively in such locales. The buttons and UI elements in MMTab/MMTabline already automatically get flipped. However, the logic of handling the tabs placements, scrolling, and drag-and-drop use manual calculations and need to be fixed up. In order to keep scrolling stable, and for tabs animation to look correct and the same as the left-to-right, we simply flip the frames we use for tabs layout, by starting from 0 in X coordinate, and grow towards the negative range. This helps keep most of the logic the same while only needing to apply the X-flip adjustment in a couple places. Also, as a minor adjustment, make the default widths of the tab just a bit wider. --- src/MacVim/MMAppController.m | 4 +- src/MacVim/MMTabline/MMTabline.h | 4 +- src/MacVim/MMTabline/MMTabline.m | 180 +++++++++++++++++++++++++------ src/MacVim/MMVimView.m | 4 +- 4 files changed, 152 insertions(+), 40 deletions(-) diff --git a/src/MacVim/MMAppController.m b/src/MacVim/MMAppController.m index 116ee74c27..1dd4ac5a4e 100644 --- a/src/MacVim/MMAppController.m +++ b/src/MacVim/MMAppController.m @@ -174,8 +174,8 @@ + (void)registerDefaults NSDictionary *macvimDefaults = [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithBool:NO], MMNoWindowKey, - [NSNumber numberWithInt:120], MMTabMinWidthKey, - [NSNumber numberWithInt:200], MMTabOptimumWidthKey, + [NSNumber numberWithInt:130], MMTabMinWidthKey, + [NSNumber numberWithInt:210], MMTabOptimumWidthKey, [NSNumber numberWithBool:YES], MMShowAddTabButtonKey, [NSNumber numberWithBool:NO], MMShowTabScrollButtonsKey, [NSNumber numberWithInt:2], MMTextInsetLeftKey, diff --git a/src/MacVim/MMTabline/MMTabline.h b/src/MacVim/MMTabline/MMTabline.h index eb59034990..a43be7828b 100644 --- a/src/MacVim/MMTabline/MMTabline.h +++ b/src/MacVim/MMTabline/MMTabline.h @@ -55,8 +55,8 @@ - (void)selectTabAtIndex:(NSInteger)index; - (MMTab *)tabAtIndex:(NSInteger)index; - (void)scrollTabToVisibleAtIndex:(NSInteger)index; -- (void)scrollLeftOneTab; -- (void)scrollRightOneTab; +- (void)scrollBackwardOneTab; +- (void)scrollForwardOneTab; - (void)setTablineSelBackground:(NSColor *)back foreground:(NSColor *)fore; @end diff --git a/src/MacVim/MMTabline/MMTabline.m b/src/MacVim/MMTabline/MMTabline.m index 9d24fa1604..ff90d46d01 100644 --- a/src/MacVim/MMTabline/MMTabline.m +++ b/src/MacVim/MMTabline/MMTabline.m @@ -8,10 +8,10 @@ CGFloat remainder; } TabWidth; -const CGFloat OptimumTabWidth = 220; -const CGFloat MinimumTabWidth = 100; -const CGFloat TabOverlap = 6; -const CGFloat ScrollOneTabAllowance = 0.25; // If we are showing 75+% of the tab, consider it to be fully shown when deciding whether to scroll to next tab. +static const CGFloat OptimumTabWidth = 200; +static const CGFloat MinimumTabWidth = 100; +static const CGFloat TabOverlap = 6; +static const CGFloat ScrollOneTabAllowance = 0.25; // If we are showing 75+% of the tab, consider it to be fully shown when deciding whether to scroll to next tab. static MMHoverButton* MakeHoverButton(MMTabline *tabline, MMHoverButtonImage imageType, NSString *tooltip, SEL action, BOOL continuous) { MMHoverButton *button = [MMHoverButton new]; @@ -44,8 +44,8 @@ @implementation MMTabline CGFloat _xOffsetForDrag; NSInteger _initialDraggedTabIndex; NSInteger _finalDraggedTabIndex; - MMHoverButton *_leftScrollButton; - MMHoverButton *_rightScrollButton; + MMHoverButton *_backwardScrollButton; + MMHoverButton *_forwardScrollButton; id _scrollWheelEventMonitor; } @@ -82,23 +82,40 @@ - (instancetype)initWithFrame:(NSRect)frameRect _scrollView.documentView = _tabsContainer; [self addSubview:_scrollView]; - _addTabButton = MakeHoverButton(self, MMHoverButtonImageAddTab, NSLocalizedString(@"create-new-tab-button", @"Create a new tab button"), @selector(addTabAtEnd), NO); - _leftScrollButton = MakeHoverButton(self, MMHoverButtonImageScrollLeft, NSLocalizedString(@"scroll-tabs-backward", @"Scroll backward button in tabs line"), @selector(scrollLeftOneTab), YES); - _rightScrollButton = MakeHoverButton(self, MMHoverButtonImageScrollRight, NSLocalizedString(@"scroll-tabs-forward", @"Scroll forward button in tabs line"), @selector(scrollRightOneTab), YES); - - [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:[_leftScrollButton][_rightScrollButton]-5-[_scrollView]-5-[_addTabButton]" options:NSLayoutFormatAlignAllCenterY metrics:nil views:NSDictionaryOfVariableBindings(_scrollView, _leftScrollButton, _rightScrollButton, _addTabButton)]]; + _addTabButton = MakeHoverButton( + self, + MMHoverButtonImageAddTab, + NSLocalizedString(@"create-new-tab-button", @"Create a new tab button"), + @selector(addTabAtEnd), + NO); + _backwardScrollButton = MakeHoverButton( + self, + [self useRightToLeft] ? MMHoverButtonImageScrollRight : MMHoverButtonImageScrollLeft, + NSLocalizedString(@"scroll-tabs-backward", @"Scroll backward button in tabs line"), + @selector(scrollBackwardOneTab), + YES); + _forwardScrollButton = MakeHoverButton( + self, + [self useRightToLeft] ? MMHoverButtonImageScrollLeft : MMHoverButtonImageScrollRight, + NSLocalizedString(@"scroll-tabs-forward", @"Scroll forward button in tabs line"), + @selector(scrollForwardOneTab), + YES); + + [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:[_backwardScrollButton][_forwardScrollButton]-5-[_scrollView]-5-[_addTabButton]" options:NSLayoutFormatAlignAllCenterY metrics:nil views:NSDictionaryOfVariableBindings(_scrollView, _backwardScrollButton, _forwardScrollButton, _addTabButton)]]; [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[_scrollView]|" options:0 metrics:nil views:@{@"_scrollView":_scrollView}]]; - _tabScrollButtonsLeadingConstraint = [NSLayoutConstraint constraintWithItem:_leftScrollButton attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeLeading multiplier:1 constant:5]; + _tabScrollButtonsLeadingConstraint = [NSLayoutConstraint constraintWithItem:_backwardScrollButton attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeLeading multiplier:1 constant:5]; [self addConstraint:_tabScrollButtonsLeadingConstraint]; _addTabButtonTrailingConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:_addTabButton attribute:NSLayoutAttributeTrailing multiplier:1 constant:5]; [self addConstraint:_addTabButtonTrailingConstraint]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didScroll:) name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; + if ([self useRightToLeft]) { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateTabsContainerBoundsForRTL:) name:NSViewFrameDidChangeNotification object:_tabsContainer]; + } [self addScrollWheelMonitor]; - } return self; } @@ -194,7 +211,7 @@ - (void)setShowsTabScrollButtons:(BOOL)showsTabScrollButtons // (see -drawRect: in MMTab.m). if (_showsTabScrollButtons != showsTabScrollButtons) { _showsTabScrollButtons = showsTabScrollButtons; - _tabScrollButtonsLeadingConstraint.constant = showsTabScrollButtons ? 5 : -((NSWidth(_leftScrollButton.frame) * 2) + 5 + MMTabShadowBlurRadius); + _tabScrollButtonsLeadingConstraint.constant = showsTabScrollButtons ? 5 : -((NSWidth(_backwardScrollButton.frame) * 2) + 5 + MMTabShadowBlurRadius); } } @@ -244,8 +261,8 @@ - (void)setTablineSelFgColor:(NSColor *)color { _tablineSelFgColor = color; _addTabButton.fgColor = color; - _leftScrollButton.fgColor = color; - _rightScrollButton.fgColor = color; + _backwardScrollButton.fgColor = color; + _forwardScrollButton.fgColor = color; for (MMTab *tab in _tabs) tab.state = tab.state; } @@ -280,6 +297,7 @@ - (NSInteger)addTabAtIndex:(NSInteger)index NSRect frame = _tabsContainer.bounds; frame.size.width = index == _tabs.count ? t.width + t.remainder : t.width; frame.origin.x = index * (t.width - TabOverlap); + frame = [self flipRectRTL:frame]; MMTab *newTab = [[MMTab alloc] initWithFrame:frame tabline:self]; [_tabs insertObject:newTab atIndex:index]; @@ -383,6 +401,7 @@ - (void)updateTabsByTags:(NSInteger *)tags len:(NSUInteger)len delayTabResize:(B NSRect frame = _tabsContainer.bounds; frame.size.width = i == (len - 1) ? t.width + t.remainder : t.width; frame.origin.x = i * (t.width - TabOverlap); + frame = [self flipRectRTL:frame]; MMTab *newTab = [[MMTab alloc] initWithFrame:frame tabline:self]; newTab.tag = tag; [newTabs addObject:newTab]; @@ -533,7 +552,7 @@ - (void)setTablineSelBackground:(NSColor *)back foreground:(NSColor *)fore #pragma mark - Helpers -NSComparisonResult SortTabsForZOrder(MMTab *tab1, MMTab *tab2, void *draggedTab) +NSComparisonResult SortTabsForZOrder(MMTab *tab1, MMTab *tab2, void *draggedTab, BOOL rtl) { // Z-order, highest to lowest: dragged, selected, hovered, rightmost if (tab1 == (__bridge MMTab *)draggedTab) return NSOrderedDescending; if (tab2 == (__bridge MMTab *)draggedTab) return NSOrderedAscending; @@ -541,11 +560,27 @@ NSComparisonResult SortTabsForZOrder(MMTab *tab1, MMTab *tab2, void *draggedTab) if (tab2.state == MMTabStateSelected) return NSOrderedAscending; if (tab1.state == MMTabStateUnselectedHover) return NSOrderedDescending; if (tab2.state == MMTabStateUnselectedHover) return NSOrderedAscending; - if (NSMinX(tab1.frame) < NSMinX(tab2.frame)) return NSOrderedAscending; - if (NSMinX(tab1.frame) > NSMinX(tab2.frame)) return NSOrderedDescending; + if (rtl) { + if (NSMinX(tab1.frame) > NSMinX(tab2.frame)) return NSOrderedAscending; + if (NSMinX(tab1.frame) < NSMinX(tab2.frame)) return NSOrderedDescending; + } else { + if (NSMinX(tab1.frame) < NSMinX(tab2.frame)) return NSOrderedAscending; + if (NSMinX(tab1.frame) > NSMinX(tab2.frame)) return NSOrderedDescending; + } return NSOrderedSame; } +NSComparisonResult SortTabsForZOrderLTR(MMTab *tab1, MMTab *tab2, void *draggedTab) +{ + return SortTabsForZOrder(tab1, tab2, draggedTab, NO); +} + + +NSComparisonResult SortTabsForZOrderRTL(MMTab *tab1, MMTab *tab2, void *draggedTab) +{ + return SortTabsForZOrder(tab1, tab2, draggedTab, YES); +} + - (TabWidth)tabWidthForTabs:(NSInteger)numTabs { // Each tab (except the first) overlaps the previous tab by TabOverlap @@ -620,9 +655,17 @@ - (void)fixupCloseButtons - (void)fixupTabZOrder { - [_tabsContainer sortSubviewsUsingFunction:SortTabsForZOrder context:(__bridge void *)(_draggedTab)]; + if ([self useRightToLeft]) { + [_tabsContainer sortSubviewsUsingFunction:SortTabsForZOrderRTL + context:(__bridge void *)(_draggedTab)]; + } else { + [_tabsContainer sortSubviewsUsingFunction:SortTabsForZOrderLTR + context:(__bridge void *)(_draggedTab)]; + } } +/// The main layout function that calculates the tab positions and animate them +/// accordingly. Call this every time tabs have been added/removed/moved. - (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate delayResize:(BOOL)delayResize { if (!self.useAnimation) @@ -656,6 +699,7 @@ - (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate delayResize:(BOOL)delayResi frame.size.width = i == _tabs.count - 1 ? t.width + t.remainder : t.width; frame.origin.x = i != 0 ? i * (t.width - TabOverlap) : 0; } + frame = [self flipRectRTL:frame]; if (shouldAnimate) { [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { context.allowsImplicitAnimation = YES; @@ -673,8 +717,20 @@ - (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate delayResize:(BOOL)delayResi 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; + const BOOL sizeDecreasing = NSWidth(frame) < NSWidth(_tabsContainer.frame); + if ([self useRightToLeft]) { + // In RTL mode we flip the X coords and grow from 0 to negative. + // See updateTabsContainerBoundsForRTL which auto-updates the + // bounds to match the frame. + frame.origin.x = -NSWidth(frame); + } + if (shouldAnimate && sizeDecreasing) { + // Need to animate to make sure we don't immediately get clamped by + // the new size if we are already scrolled all the way to the back. + _tabsContainer.animator.frame = frame; + } else { + _tabsContainer.frame = frame; + } [self updateTabScrollButtonsEnabledState]; } } @@ -684,6 +740,41 @@ - (void)fixupLayoutWithAnimation:(BOOL)shouldAnimate [self fixupLayoutWithAnimation:shouldAnimate delayResize:NO]; } +#pragma mark - Right-to-left (RTL) support + +- (BOOL)useRightToLeft +{ + // MMTabs support RTL locales. In such locales user interface items are + // laid out from right to left. The layout of hover buttons and views are + // automatically flipped by AppKit, but we need to handle this manually in + // the tab placement logic since that is custom logic. + return self.userInterfaceLayoutDirection == NSUserInterfaceLayoutDirectionRightToLeft; +} + +- (void)updateTabsContainerBoundsForRTL:(NSNotification *)notification +{ + // In RTL mode, we grow the tabs container to the left. We want to preserve + // stability of the scroll view's bounds, and also have the tabs animate + // correctly. To do this, we have to make sure the container bounds matches + // the frame at all times. This "cancels out" the negative X offsets with + // each other and ease calculations. + // E.g. an MMTab with origin (-100,0) inside the _tabsContainer coordinate + // space will actually be (-100,0) in the scroll view as well. + // In LTR mode we don't need this, since _tabsContainer's origin is always + // at (0,0). + _tabsContainer.bounds = _tabsContainer.frame; +} + +- (NSRect)flipRectRTL:(NSRect)frame +{ + if ([self useRightToLeft]) { + // In right-to-left mode, we flip the X coordinates for all the tabs so + // they start at 0 and grow in the negative direction. + frame.origin.x = -NSMaxX(frame); + } + return frame; +} + #pragma mark - Mouse - (void)updateTrackingAreas @@ -796,9 +887,15 @@ - (void)mouseDragged:(NSEvent *)event [self fixupTabZOrder]; [_draggedTab setFrameOrigin:NSMakePoint(mouse.x - _xOffsetForDrag, 0)]; MMTab *selectedTab = _selectedTabIndex == -1 ? nil : _tabs[_selectedTabIndex]; + const BOOL rightToLeft = [self useRightToLeft]; [_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; + if (rightToLeft) { + if (NSMaxX(t1.frame) >= NSMaxX(t2.frame)) return NSOrderedAscending; + if (NSMaxX(t1.frame) < NSMaxX(t2.frame)) return NSOrderedDescending; + } else { + if (NSMinX(t1.frame) <= NSMinX(t2.frame)) return NSOrderedAscending; + if (NSMinX(t1.frame) > NSMinX(t2.frame)) return NSOrderedDescending; + } return NSOrderedSame; }]; _selectedTabIndex = _selectedTabIndex == -1 ? -1 : [_tabs indexOfObject:selectedTab]; @@ -820,11 +917,18 @@ - (void)updateTabScrollButtonsEnabledState // on either side of _scrollView. NSRect clipBounds = _scrollView.contentView.bounds; if (NSWidth(_tabsContainer.frame) <= NSWidth(clipBounds)) { - _leftScrollButton.enabled = NO; - _rightScrollButton.enabled = NO; + _backwardScrollButton.enabled = NO; + _forwardScrollButton.enabled = NO; } else { - _leftScrollButton.enabled = clipBounds.origin.x > 0; - _rightScrollButton.enabled = clipBounds.origin.x + NSWidth(clipBounds) < NSMaxX(_tabsContainer.frame); + BOOL scrollLeftEnabled = NSMinX(clipBounds) > NSMinX(_tabsContainer.frame); + BOOL scrollRightEnabled = NSMaxX(clipBounds) < NSMaxX(_tabsContainer.frame); + if ([self useRightToLeft]) { + _backwardScrollButton.enabled = scrollRightEnabled; + _forwardScrollButton.enabled = scrollLeftEnabled; + } else { + _backwardScrollButton.enabled = scrollLeftEnabled; + _forwardScrollButton.enabled = scrollRightEnabled; + } } } @@ -874,14 +978,18 @@ - (void)scrollTabToVisibleAtIndex:(NSInteger)index } } -- (void)scrollLeftOneTab +- (void)scrollBackwardOneTab { NSRect clipBounds = _scrollView.contentView.animator.bounds; for (NSInteger i = _tabs.count - 1; i >= 0; i--) { NSRect tabFrame = _tabs[i].frame; if (!NSContainsRect(clipBounds, tabFrame)) { - CGFloat allowance = i == 0 ? 0 : NSWidth(tabFrame) * ScrollOneTabAllowance; - if (NSMinX(tabFrame) + allowance < NSMinX(clipBounds)) { + const CGFloat allowance = (i == 0) ? + 0 : NSWidth(tabFrame) * ScrollOneTabAllowance; + const BOOL outOfBounds = [self useRightToLeft] ? + NSMaxX(tabFrame) - allowance > NSMaxX(clipBounds) : + NSMinX(tabFrame) + allowance < NSMinX(clipBounds); + if (outOfBounds) { [self scrollTabToVisibleAtIndex:i]; break; } @@ -889,14 +997,18 @@ - (void)scrollLeftOneTab } } -- (void)scrollRightOneTab +- (void)scrollForwardOneTab { NSRect clipBounds = _scrollView.contentView.animator.bounds; for (NSInteger i = 0; i < _tabs.count; i++) { NSRect tabFrame = _tabs[i].frame; if (!NSContainsRect(clipBounds, tabFrame)) { - CGFloat allowance = i == _tabs.count - 1 ? 0 : NSWidth(tabFrame) * ScrollOneTabAllowance; - if (NSMaxX(tabFrame) - allowance > NSMaxX(clipBounds)) { + const CGFloat allowance = (i == _tabs.count - 1) ? + 0 : NSWidth(tabFrame) * ScrollOneTabAllowance; + const BOOL outOfBounds = [self useRightToLeft] ? + NSMinX(tabFrame) + allowance < NSMinX(clipBounds) : + NSMaxX(tabFrame) - allowance > NSMaxX(clipBounds); + if (outOfBounds) { [self scrollTabToVisibleAtIndex:i]; break; } diff --git a/src/MacVim/MMVimView.m b/src/MacVim/MMVimView.m index b696ac32ee..96c6273094 100644 --- a/src/MacVim/MMVimView.m +++ b/src/MacVim/MMVimView.m @@ -264,12 +264,12 @@ - (IBAction)scrollToCurrentTab:(id)sender - (IBAction)scrollBackwardOneTab:(id)sender { - [tabline scrollLeftOneTab]; + [tabline scrollBackwardOneTab]; } - (IBAction)scrollForwardOneTab:(id)sender { - [tabline scrollRightOneTab]; + [tabline scrollForwardOneTab]; } - (void)showTabline:(BOOL)on