diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h index 3d5a4dfce0..ae8d4298b4 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h @@ -58,6 +58,16 @@ @property(nonatomic) PSMProgress progress; @property(nonatomic) BOOL isProcessing; @property(nonatomic, assign) BOOL isPinned; +@property(nonatomic, assign) BOOL isGroupHeader; +@property(nonatomic, assign) BOOL isGroupMember; +@property(nonatomic, assign) BOOL isGroupCollapsed; +@property(nonatomic, assign) BOOL isGroupActive; +@property(nonatomic, assign) BOOL isMultiSelected; +@property(nonatomic, assign) CGFloat cellAlpha; +@property(nonatomic, assign) BOOL isAnimatingCollapse; +@property(nonatomic, copy) NSString *groupName; +@property(nonatomic, retain) NSColor *groupColor; +@property(nonatomic, assign) NSInteger groupMemberCount; // creation/destruction - (id)initWithControlView:(PSMTabBarControl *)controlView; diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m index aeb1bde814..adefac3102 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m @@ -185,7 +185,26 @@ @implementation PSMTabBarCell { NSMutableArray *_subtitleCache; NSTrackingArea *_cellTrackingArea; NSTrackingArea *_closeButtonTrackingArea; -} + BOOL _isGroupHeader; + BOOL _isGroupMember; + BOOL _isGroupCollapsed; + BOOL _isGroupActive; + BOOL _isMultiSelected; + CGFloat _cellAlpha; + NSString *_groupName; + NSColor *_groupColor; + NSInteger _groupMemberCount; +} + +@synthesize isGroupHeader = _isGroupHeader; +@synthesize isGroupMember = _isGroupMember; +@synthesize isGroupCollapsed = _isGroupCollapsed; +@synthesize isGroupActive = _isGroupActive; +@synthesize isMultiSelected = _isMultiSelected; +@synthesize cellAlpha = _cellAlpha; +@synthesize groupName = _groupName; +@synthesize groupColor = _groupColor; +@synthesize groupMemberCount = _groupMemberCount; #pragma mark - Creation/Destruction @@ -203,6 +222,15 @@ - (id)initWithControlView:(PSMTabBarControl *)controlView { _hasCloseButton = YES; _modifierString = [@"" copy]; _truncationStyle = NSLineBreakByTruncatingTail; + _isGroupHeader = NO; + _isGroupMember = NO; + _isGroupCollapsed = NO; + _isGroupActive = NO; + _isMultiSelected = NO; + _cellAlpha = 1.0; + _groupName = nil; + _groupColor = nil; + _groupMemberCount = 0; [self setUpAccessibilityElement]; } return self; diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h index a8242152df..75b4ed684d 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h @@ -157,6 +157,11 @@ extern PSMTabBarControlOptionKey PSMTabBarControlOptionPUAFontProvider; // id

*)tabViewItems; @end @@ -230,12 +235,22 @@ extern const CGFloat PSMTabBarProgressBarHeight; // tab information - (NSMutableArray *)representedTabViewItems; +- (NSMutableArray *)cells; - (int)numberOfVisibleTabs; // special effects - (void)hideTabBar:(BOOL)hide animate:(BOOL)animate; +- (void)update; +- (void)update:(BOOL)animate; +- (void)markNextInsertionsAsAnimated:(NSInteger)count; +- (void)beginCollapseAnimationForTabViewItems:(NSArray *)items completion:(void (^)(void))completion; +- (void)cancelCollapseAnimation; - (BOOL)isTabBarHidden; +// Frame of the cell displaying the given tab view item, in the receiver's +// coordinate system. Returns NSZeroRect if no cell currently shows the item. +- (NSRect)frameOfCellForTabViewItem:(NSTabViewItem *)item; + // internal bindings methods also used by the tab drag assistant - (void)bindPropertiesForCell:(PSMTabBarCell *)cell andTabViewItem:(NSTabViewItem *)item; - (void)removeTabForCell:(PSMTabBarCell *)cell; @@ -245,6 +260,7 @@ extern const CGFloat PSMTabBarProgressBarHeight; // Internal inset. Ensures nothing but background is drawn in this are. @property(nonatomic, assign) NSEdgeInsets insets; @property(nonatomic) CGFloat height; +@property(nonatomic, assign) BOOL lastDragWasGroupHeader; - (void)setTabColor:(nullable NSColor *)aColor forTabViewItem:(NSTabViewItem *) tabViewItem; - (nullable NSColor*)tabColorForTabViewItem:(NSTabViewItem*)tabViewItem; diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m index 03b1341789..86646c2bfd 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m @@ -142,6 +142,112 @@ - (BOOL)isEqual:(id)object { @end +// Minimal pill button used for the multi-select "Group (N)" action. +@interface PSMGroupPillButton : NSView +@property (nonatomic, copy) NSString *title; +@property (nonatomic, weak) id target; +@property (nonatomic, assign) SEL action; +@end + +@implementation PSMGroupPillButton { + NSTrackingArea *_trackingArea; + BOOL _hovered; + BOOL _pressed; +} + +- (instancetype)init { + self = [super initWithFrame:NSZeroRect]; + if (self) { + self.wantsLayer = NO; + } + return self; +} + +- (void)dealloc { + [_trackingArea release]; + [_title release]; + [super dealloc]; +} + +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + if (_trackingArea) { + [self removeTrackingArea:_trackingArea]; + [_trackingArea release]; + } + _trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveInKeyWindow + owner:self + userInfo:nil]; + [self addTrackingArea:_trackingArea]; +} + +- (NSSize)intrinsicContentSize { + NSFont *font = [NSFont systemFontOfSize:11.0 weight:NSFontWeightMedium]; + NSSize textSize = [_title sizeWithAttributes:@{NSFontAttributeName: font}]; + return NSMakeSize(textSize.width + 20.0, 20.0); +} + +- (BOOL)hasDarkAppearance { + NSAppearanceName bestMatch = + [self.effectiveAppearance bestMatchFromAppearancesWithNames:@[ NSAppearanceNameDarkAqua, + NSAppearanceNameVibrantDark, + NSAppearanceNameAqua, + NSAppearanceNameVibrantLight ]]; + return ([bestMatch isEqualToString:NSAppearanceNameDarkAqua] || + [bestMatch isEqualToString:NSAppearanceNameVibrantDark]); +} + +- (void)drawRect:(NSRect)dirtyRect { + NSRect r = NSInsetRect(self.bounds, 0.5, 0.5); + CGFloat radius = NSHeight(r) / 2.0; + NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:r xRadius:radius yRadius:radius]; + const BOOL dark = [self hasDarkAppearance]; + const CGFloat foregroundWhite = dark ? 1.0 : 0.0; + const CGFloat borderWhite = dark ? 0.85 : 0.15; + + // Fill: transparent normally, subtle tint on hover/press. + if (_pressed) { + [[NSColor colorWithWhite:foregroundWhite alpha:0.12] setFill]; + [pill fill]; + } else if (_hovered) { + [[NSColor colorWithWhite:foregroundWhite alpha:0.07] setFill]; + [pill fill]; + } + + // Border. + CGFloat borderAlpha = _hovered ? 0.5 : 0.28; + [[NSColor colorWithWhite:borderWhite alpha:borderAlpha] setStroke]; + [pill setLineWidth:1.0]; + [pill stroke]; + + // Label. + CGFloat textAlpha = _hovered ? 1.0 : 0.72; + NSDictionary *attrs = @{ + NSFontAttributeName: [NSFont systemFontOfSize:11.0 weight:NSFontWeightMedium], + NSForegroundColorAttributeName: [NSColor colorWithWhite:foregroundWhite alpha:textAlpha] + }; + NSSize ts = [_title sizeWithAttributes:attrs]; + [_title drawAtPoint:NSMakePoint(NSMidX(self.bounds) - ts.width / 2.0, + NSMidY(self.bounds) - ts.height / 2.0) + withAttributes:attrs]; +} + +- (void)mouseEntered:(NSEvent *)event { _hovered = YES; [self setNeedsDisplay:YES]; } +- (void)mouseExited:(NSEvent *)event { _hovered = NO; _pressed = NO; [self setNeedsDisplay:YES]; } +- (void)mouseDown:(NSEvent *)event { _pressed = YES; [self setNeedsDisplay:YES]; } +- (void)mouseUp:(NSEvent *)event { + _pressed = NO; + [self setNeedsDisplay:YES]; + if (NSMouseInRect([self convertPoint:event.locationInWindow fromView:nil], self.bounds, self.isFlipped)) { + if (_target && _action) { + [NSApp sendAction:_action to:_target from:self]; + } + } +} + +@end + @interface PSMTabBarControl () - (void)removeTabProgressBarForCell:(PSMTabBarCell *)cell; @end @@ -154,7 +260,22 @@ @implementation PSMTabBarControl { // drawing style NSTimer *_animationTimer; - float _animationDelta; + NSArray *_animationStartWidths; // cell widths captured when the resize animation began + NSTimeInterval _animationStartTime; + + // fade-in / slide-in animation for new cells (group expand only) + NSMutableSet *_fadingInCells; + NSTimer *_fadeTimer; + NSInteger _animatedInsertionCount; + + // slide-out / fade-out animation for collapsing cells (group collapse) + NSMutableSet *_collapsingCells; + NSTimer *_collapseTimer; + NSMutableArray *_collapseCompletions; + + // deferred single-click on group headers (to avoid firing toggle on double-click) + NSTimer *_groupHeaderSingleClickTimer; + PSMTabBarCell *_pendingGroupHeaderClickCell; // vertical tab resizing BOOL _resizing; @@ -184,6 +305,10 @@ @implementation PSMTabBarControl { NSMapTable *_tabProgressBars; NSMutableArray *_tooltips; NSInteger _toolTipCoalescing; + + // multi-selection (cmd+click) + NSMutableSet *_multiSelectedTabViewItems; + PSMGroupPillButton *_groupSelectionButton; } #pragma mark - @@ -371,6 +496,9 @@ - (void)setupButtons { } - (void)dealloc { + [_fadeTimer invalidate]; + [_collapseTimer invalidate]; + [_groupHeaderSingleClickTimer invalidate]; [[NSNotificationCenter defaultCenter] removeObserver:self]; // Remove bindings. @@ -396,6 +524,12 @@ - (void)dealloc { [_style release]; [_tooltips release]; _tooltips = nil; + [_animationStartWidths release]; + [_fadingInCells release]; + [_collapsingCells release]; + [_collapseCompletions release]; + [_multiSelectedTabViewItems release]; + [_groupSelectionButton release]; [self unregisterDraggedTypes]; @@ -702,6 +836,23 @@ - (void)addTabViewItem:(NSTabViewItem *)item atIndex:(NSUInteger)i { // add to collection [_cells insertObject:cell atIndex:i]; + // Only animate group-expand insertions; regular new tabs appear instantly. + if (_animatedInsertionCount > 0) { + _animatedInsertionCount--; + cell.cellAlpha = 0.0; + if (!_fadingInCells) { + _fadingInCells = [[NSMutableSet alloc] init]; + } + [_fadingInCells addObject:cell]; + if (!_fadeTimer) { + _fadeTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / 60.0 + target:self + selector:@selector(_tickFadeIn:) + userInfo:nil + repeats:YES]; + } + } + // bind it up [self initializeStateForCell:cell]; [self bindPropertiesForCell:cell andTabViewItem:item]; @@ -713,6 +864,9 @@ - (void)addTabViewItem:(NSTabViewItem *)item { } - (void)removeTabForCell:(PSMTabBarCell *)cell { + if (!cell) { + return; + } // unbind [cell unbind:@"title"]; @@ -731,6 +885,7 @@ - (void)removeTabForCell:(PSMTabBarCell *)cell { // pull from collection [_cells removeObject:cell]; + [_fadingInCells removeObject:cell]; } - (BOOL)shouldShowCustomProgressBarForTabCell:(PSMTabBarCell *)cell { @@ -1163,6 +1318,118 @@ - (void)update { [self update:NO]; } +- (void)markNextInsertionsAsAnimated:(NSInteger)count { + _animatedInsertionCount += count; +} + +- (void)beginCollapseAnimationForTabViewItems:(NSArray *)items + completion:(void (^)(void))completion { + if (!_collapsingCells) { + _collapsingCells = [[NSMutableSet alloc] init]; + } + for (NSTabViewItem *item in items) { + for (PSMTabBarCell *cell in _cells) { + if ([cell.representedObject isEqual:item]) { + cell.cellAlpha = 1.0; + [_collapsingCells addObject:cell]; + break; + } + } + } + if (!_collapseCompletions) { + _collapseCompletions = [[NSMutableArray alloc] init]; + } + if (completion) { + [_collapseCompletions addObject:[[completion copy] autorelease]]; + } + if (!_collapseTimer) { + _collapseTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / 60.0 + target:self + selector:@selector(_tickCollapse:) + userInfo:nil + repeats:YES]; + } +} + +- (void)cancelCollapseAnimation { + [_collapseTimer invalidate]; + _collapseTimer = nil; + for (PSMTabBarCell *cell in _collapsingCells) { + cell.cellAlpha = 1.0; + } + [_collapsingCells removeAllObjects]; + NSArray *completions = [[_collapseCompletions copy] autorelease]; + [_collapseCompletions removeAllObjects]; + for (void (^cb)(void) in completions) { cb(); } +} + +- (NSRect)frameOfCellForTabViewItem:(NSTabViewItem *)item { + for (PSMTabBarCell *cell in _cells) { + if ([cell.representedObject isEqual:item]) { + return cell.frame; + } + } + return NSZeroRect; +} + +- (void)_fireGroupHeaderSingleClick:(NSTimer *)timer { + _groupHeaderSingleClickTimer = nil; + PSMTabBarCell *cell = _pendingGroupHeaderClickCell; + _pendingGroupHeaderClickCell = nil; + if (cell) { + [self tabClick:cell]; + } +} + +- (void)_tickCollapse:(NSTimer *)timer { + const CGFloat delta = (1.0 / 60.0) / 0.12; // 120ms total — collapse faster than expand + NSMutableSet *completed = [NSMutableSet set]; + for (PSMTabBarCell *cell in _collapsingCells) { + cell.isAnimatingCollapse = YES; + cell.cellAlpha = MAX(0.0, cell.cellAlpha - delta); + if (cell.cellAlpha <= 0.0) { + [completed addObject:cell]; + } + } + for (PSMTabBarCell *cell in completed) { + cell.isAnimatingCollapse = NO; + } + [_collapsingCells minusSet:completed]; + if ([PSMTabBarControl isAnyDragInProgress]) { + [self setNeedsDisplay:YES]; + } else { + [self update]; + } + if (_collapsingCells.count == 0) { + [timer invalidate]; + _collapseTimer = nil; + NSArray *completions = [[_collapseCompletions copy] autorelease]; + [_collapseCompletions removeAllObjects]; + for (void (^cb)(void) in completions) { cb(); } + } +} + +- (void)_tickFadeIn:(NSTimer *)timer { + const CGFloat delta = (1.0 / 60.0) / 0.25; // 250ms total — expand + NSMutableSet *completed = [NSMutableSet set]; + for (PSMTabBarCell *cell in _fadingInCells) { + cell.cellAlpha = MIN(1.0, cell.cellAlpha + delta); + if (cell.cellAlpha >= 1.0) { + [completed addObject:cell]; + } + } + [_fadingInCells minusSet:completed]; + if (_fadingInCells.count == 0) { + [timer invalidate]; + _fadeTimer = nil; + } + if ([PSMTabBarControl isAnyDragInProgress]) { + [self setNeedsDisplay:YES]; + } else { + [self update]; + } +} + - (void)setFrame:(NSRect)frame { [super setFrame:frame]; [self syncTabProgressBars]; @@ -1268,8 +1535,14 @@ - (void)reallyUpdate:(BOOL)animate { [_animationTimer invalidate]; } - _animationDelta = 0.0f; - _animationTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / 30.0 + NSMutableArray *startWidths = [NSMutableArray arrayWithCapacity:cellCount]; + for (PSMTabBarCell *cell in _cells) { + [startWidths addObject:@(cell.frame.size.width)]; + } + [_animationStartWidths release]; + _animationStartWidths = [startWidths copy]; + _animationStartTime = [NSDate timeIntervalSinceReferenceDate]; + _animationTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / 60.0 target:self selector:@selector(_animateCells:) userInfo:[self cellWidthsForHorizontalArrangementWithOverflow:_lainOutWithOverflow] @@ -1341,6 +1614,8 @@ - (CGFloat)totalPinnedSpaceForPinnedCount:(NSUInteger)pinnedCount unpinnedCount: CGFloat width; if (cell.isPinned) { width = _pinnedTabWidth; + } else if (cell.isGroupHeader) { + width = [cell desiredWidthOfCell]; } else { width = MAX(_cellMinWidth, MIN([cell desiredWidthOfCell], _cellMaxWidth)); } @@ -1363,11 +1638,14 @@ - (CGFloat)totalPinnedSpaceForPinnedCount:(NSUInteger)pinnedCount unpinnedCount: - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { const CGFloat availableWidth = [self availableCellWidthWithOverflow:withOverflow]; const NSUInteger pinnedCount = [self numberOfPinnedCells]; - const NSUInteger unpinnedCount = _cells.count - pinnedCount; - const CGFloat pinnedSpace = [self totalPinnedSpaceForPinnedCount:pinnedCount unpinnedCount:unpinnedCount]; + const CGFloat pinnedSpace = [self totalPinnedSpaceForPinnedCount:pinnedCount unpinnedCount:_cells.count - pinnedCount]; const CGFloat unpinnedAvailable = availableWidth - pinnedSpace; - BOOL canFitAllCellsOptimally = (self.cellOptimumWidth * unpinnedCount <= unpinnedAvailable); - return !self.stretchCellsToFit && canFitAllCellsOptimally; + CGFloat totalUnpinnedDesiredWidth = 0; + for (PSMTabBarCell *c in _cells) { + if (c.isPinned) { continue; } + totalUnpinnedDesiredWidth += c.isGroupHeader ? [c desiredWidthOfCell] : self.cellOptimumWidth; + } + return !self.stretchCellsToFit && (totalUnpinnedDesiredWidth <= unpinnedAvailable); } - (NSArray *)cellWidthsForHorizontalArrangementWithOverflow:(BOOL)withOverflow { @@ -1388,8 +1666,9 @@ - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { // No pinned cells NSMutableArray *newWidths = [NSMutableArray array]; if ([self shouldUseOptimalWidthWithOverflow:withOverflow]) { - for (int i = 0; i < cellCount; i++) { - [newWidths addObject:@(_cellOptimumWidth)]; + for (PSMTabBarCell *c in _cells) { + CGFloat w = c.isGroupHeader ? [c desiredWidthOfCell] : _cellOptimumWidth; + [newWidths addObject:@(w)]; } } else { const BOOL canFitAllCellsMinimally = (self.cellMinWidth * cellCount + intercellSpacing * MAX(0, (cellCount - 1)) <= availableWidth); @@ -1402,11 +1681,11 @@ - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { numberOfVisibleCells -= 1; } } - [self computeCellFramesInContainerOfWidth:availableWidth - numberOfVisibleCells:numberOfVisibleCells - intercellSpacing:intercellSpacing - scale:2.0 - frames:newWidths]; + [self computeCompressedWidthsForCells:_cells + count:numberOfVisibleCells + containerWidth:availableWidth + intercellSpacing:intercellSpacing + frames:newWidths]; } return newWidths; } @@ -1421,8 +1700,10 @@ - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { if (unpinnedCount > 0 && unpinnedContainerWidth > 0) { if ([self shouldUseOptimalWidthWithOverflow:withOverflow]) { numberOfVisibleUnpinned = unpinnedCount; - for (NSUInteger i = 0; i < unpinnedCount; i++) { - [unpinnedWidths addObject:@(_cellOptimumWidth)]; + for (PSMTabBarCell *c in _cells) { + if (c.isPinned) { continue; } + CGFloat w = c.isGroupHeader ? [c desiredWidthOfCell] : _cellOptimumWidth; + [unpinnedWidths addObject:@(w)]; } } else { const BOOL canFitAllUnpinned = (self.cellMinWidth * unpinnedCount + @@ -1438,11 +1719,15 @@ - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { numberOfVisibleUnpinned = MAX(0, numberOfVisibleUnpinned); } if (numberOfVisibleUnpinned > 0) { - [self computeCellFramesInContainerOfWidth:unpinnedContainerWidth - numberOfVisibleCells:numberOfVisibleUnpinned - intercellSpacing:intercellSpacing - scale:2.0 - frames:unpinnedWidths]; + NSMutableArray *unpinnedCells = [NSMutableArray array]; + for (PSMTabBarCell *c in _cells) { + if (!c.isPinned) { [unpinnedCells addObject:c]; } + } + [self computeCompressedWidthsForCells:unpinnedCells + count:numberOfVisibleUnpinned + containerWidth:unpinnedContainerWidth + intercellSpacing:intercellSpacing + frames:unpinnedWidths]; } } } @@ -1472,6 +1757,34 @@ - (BOOL)shouldUseOptimalWidthWithOverflow:(BOOL)withOverflow { return result; } +- (void)computeCompressedWidthsForCells:(NSArray *)cells + count:(NSInteger)n + containerWidth:(CGFloat)containerWidth + intercellSpacing:(CGFloat)intercellSpacing + frames:(NSMutableArray *)outWidths { + CGFloat groupHeaderTotal = 0; + NSInteger regularCount = 0; + NSInteger i = 0; + for (PSMTabBarCell *c in cells) { + if (i >= n) break; + if (c.isGroupHeader) { + groupHeaderTotal += [c desiredWidthOfCell]; + } else { + regularCount++; + } + i++; + } + const CGFloat totalSpacing = (n > 1) ? (n - 1) * intercellSpacing : 0; + const CGFloat remaining = MAX(0, containerWidth - groupHeaderTotal - totalSpacing); + const CGFloat regularWidth = regularCount > 0 ? floor(remaining / regularCount) : 0; + i = 0; + for (PSMTabBarCell *c in cells) { + if (i >= n) break; + [outWidths addObject:c.isGroupHeader ? @([c desiredWidthOfCell]) : @(regularWidth)]; + i++; + } +} + - (void)computeCellFramesInContainerOfWidth:(CGFloat)containerWidth numberOfVisibleCells:(NSInteger)n intercellSpacing:(CGFloat)intercellSpacing @@ -1525,48 +1838,37 @@ - (void)_removeCellTrackingRects { - (void)_animateCells:(NSTimer *)timer { NSArray *targetWidths = [timer userInfo]; - int i, numberOfVisibleCells = [targetWidths count]; - float totalChange = 0.0f; - BOOL updated = NO; + const NSInteger numberOfVisibleCells = [targetWidths count]; + const NSTimeInterval duration = 0.25; + const NSTimeInterval elapsed = [NSDate timeIntervalSinceReferenceDate] - _animationStartTime; + const CGFloat progress = MIN(1.0, elapsed / duration); + const CGFloat eased = 1.0 - pow(1.0 - progress, 3.0); + + CGFloat totalChange = 0.0; + NSInteger i = 0; + for (PSMTabBarCell *currentCell in _cells) { + NSRect cellFrame = [currentCell frame]; + cellFrame.origin.x += totalChange; - if ([_cells count] > 0) { - //compare our target widths with the current widths and move towards the target - for (i = 0; i < [_cells count]; i++) { - PSMTabBarCell *currentCell = [_cells objectAtIndex:i]; - NSRect cellFrame = [currentCell frame]; - cellFrame.origin.x += totalChange; - - if (i < numberOfVisibleCells) { - float target = [[targetWidths objectAtIndex:i] floatValue]; - - if (currentCell.isPinned) { - // Pinned cells snap to target width immediately - no gradual animation. - totalChange += target - cellFrame.size.width; - cellFrame.size.width = target; - [currentCell setFrame:cellFrame]; - } else if (fabs(cellFrame.size.width - target) < _animationDelta) { - cellFrame.size.width = target; - totalChange += cellFrame.size.width - target; - [currentCell setFrame:cellFrame]; - } else if (cellFrame.size.width > target) { - cellFrame.size.width -= _animationDelta; - totalChange -= _animationDelta; - updated = YES; - } else if (cellFrame.size.width < target) { - cellFrame.size.width += _animationDelta; - totalChange += _animationDelta; - [currentCell setFrame:cellFrame]; - updated = YES; - } + if (i < numberOfVisibleCells) { + const CGFloat target = [[targetWidths objectAtIndex:i] doubleValue]; + CGFloat newWidth; + if (currentCell.isPinned || i >= (NSInteger)_animationStartWidths.count) { + // Pinned cells snap to target width immediately - no gradual animation. + newWidth = target; + } else { + const CGFloat start = [_animationStartWidths[i] doubleValue]; + newWidth = start + (target - start) * eased; } - - [currentCell setFrame:cellFrame]; + totalChange += newWidth - cellFrame.size.width; + cellFrame.size.width = newWidth; } - _animationDelta += 4.0f; + [currentCell setFrame:cellFrame]; + i++; } - if (!updated) { + if (progress >= 1.0) { [self finishUpdateWithRegularWidths:targetWidths widthsWithOverflow:targetWidths]; [timer invalidate]; @@ -1626,8 +1928,19 @@ - (NSMenu *)_setupCells:(NSArray *)newValues { int tabState = 0; if (i < numberOfVisibleCells) { // set cell frame + const CGFloat fullCellWidth = [[newValues objectAtIndex:i] floatValue]; + CGFloat animCellWidth; + if (cell.cellAlpha >= 1.0) { + animCellWidth = fullCellWidth; + } else { + const CGFloat t = cell.cellAlpha; + const CGFloat easedT = [_collapsingCells containsObject:cell] + ? t * t * t // easeInCubic — accelerates into collapse + : 1.0 - pow(1.0 - t, 3.0); // easeOutCubic — decelerates into expansion + animCellWidth = fullCellWidth * easedT; + } if ([self orientation] == PSMTabBarHorizontalOrientation) { - cellRect.size.width = [[newValues objectAtIndex:i] floatValue]; + cellRect.size.width = animCellWidth; } else { cellRect.size.width = [self frame].size.width; cellRect.origin.y = [[newValues objectAtIndex:i] floatValue]; @@ -1708,7 +2021,7 @@ - (NSMenu *)_setupCells:(NSArray *)newValues { } // next... - cellRect.origin.x += [[newValues objectAtIndex:i] floatValue] + intercellSpacing; + cellRect.origin.x += animCellWidth + intercellSpacing; } else { // set up menu items @@ -1843,14 +2156,16 @@ - (void)mouseDown:(NSEvent *)theEvent { if ([theEvent clickCount] == 1) { const NSEventModifierFlags mask = NSEventModifierFlagOption; if (_selectsTabsOnMouseDown && (theEvent.modifierFlags & mask) == 0) { - if (cell.state != NSControlStateValueOn) { - _preDragSelectedTabIndex = [[self tabView] indexOfTabViewItem:self.tabView.selectedTabViewItem]; - } else { - // Because we always want it to switch tabs, don't save - // the index if you're dragging the current tab. - _preDragSelectedTabIndex = NSNotFound; + // Skip group headers on mouseDown — they need to wait for mouseUp to + // distinguish a single-click toggle from a double-click rename. + if (![[cell.representedObject identifier] isKindOfClass:[iTermTabGroup class]]) { + if (cell.state != NSControlStateValueOn) { + _preDragSelectedTabIndex = [[self tabView] indexOfTabViewItem:self.tabView.selectedTabViewItem]; + } else { + _preDragSelectedTabIndex = NSNotFound; + } + [self tabClick:cell]; } - [self tabClick:cell]; } } } @@ -1933,10 +2248,16 @@ - (void)mouseDragged:(NSEvent *)theEvent { float dy = fabs(currentPoint.y - trackingStartPoint.y); float distance = sqrt(dx * dx + dy * dy); - if (distance >= self.minimumTabDragDistance && !_didDrag && ![[PSMTabDragAssistant sharedDragAssistant] isDragging] && + if (distance >= self.minimumTabDragDistance && !_didDrag && + ![[PSMTabDragAssistant sharedDragAssistant] isDragging] && [[self delegate] respondsToSelector:@selector(tabView:shouldDragTabViewItem:fromTabBar:)] && [[self delegate] tabView:_tabView shouldDragTabViewItem:[cell representedObject] fromTabBar:self]) { _didDrag = YES; + self.lastDragWasGroupHeader = cell.isGroupHeader; + if (cell.isGroupHeader && + [[self delegate] respondsToSelector:@selector(tabView:willBeginDraggingGroupHeaderTabViewItem:)]) { + [[self delegate] tabView:_tabView willBeginDraggingGroupHeaderTabViewItem:[cell representedObject]]; + } ILog(@"Start dragging with mouse down event %@ in window %p with frame %@", [self lastMouseDownEvent], self.window, NSStringFromRect(self.window.frame)); [[PSMTabDragAssistant sharedDragAssistant] startDraggingCell:cell fromTabBar:self withMouseDownEvent:[self lastMouseDownEvent]]; } @@ -2008,10 +2329,31 @@ - (void)handleMouseUp:(NSEvent * _Nonnull)theEvent { [mouseDownCell setCloseButtonPressed:NO]; switch (theEvent.clickCount) { case 1: + if (_selectsTabsOnMouseDown && (theEvent.modifierFlags & NSEventModifierFlagCommand) != 0) { + // Cmd+click multi-select was already handled on mouse-down; skip to avoid double-toggle. + return; + } + if (cell.isGroupHeader) { + // Defer: if a second click arrives before doubleClickInterval, the timer is + // cancelled in case 2 so a double-click toggles collapse instead of opening + // the management popover that a lone single-click would. + [_groupHeaderSingleClickTimer invalidate]; + _pendingGroupHeaderClickCell = cell; + _groupHeaderSingleClickTimer = + [NSTimer scheduledTimerWithTimeInterval:[NSEvent doubleClickInterval] + target:self + selector:@selector(_fireGroupHeaderSingleClick:) + userInfo:nil + repeats:NO]; + return; + } [self tabClick:cell]; return; case 2: + [_groupHeaderSingleClickTimer invalidate]; + _groupHeaderSingleClickTimer = nil; + _pendingGroupHeaderClickCell = nil; [self tabDoubleClick:cell]; return; @@ -2028,12 +2370,30 @@ - (void)handleMouseUp:(NSEvent * _Nonnull)theEvent { - (NSMenu *)menuForEvent:(NSEvent *)event { NSMenu *menu = nil; - NSTabViewItem *item = [[self cellForPoint:[self convertPoint:[event locationInWindow] fromView:nil] cellFrame:nil] representedObject]; + PSMTabBarCell *cell = [self cellForPoint:[self convertPoint:[event locationInWindow] fromView:nil] cellFrame:nil]; + NSTabViewItem *item = [cell representedObject]; + + if (item && cell.isGroupHeader) { + if ([[self delegate] respondsToSelector:@selector(tabView:menuForGroupHeaderTabViewItem:)]) { + menu = [[self delegate] tabView:_tabView menuForGroupHeaderTabViewItem:item]; + } + return menu; + } if (item && [[self delegate] respondsToSelector:@selector(tabView:menuForTabViewItem:)]) { menu = [[self delegate] tabView:_tabView menuForTabViewItem:item]; } - else if (!item) { + + if (_multiSelectedTabViewItems.count > 1 && menu) { + [menu addItem:[NSMenuItem separatorItem]]; + NSString *title = [NSString stringWithFormat:@"Group %lu Selected Tabs", (unsigned long)_multiSelectedTabViewItems.count]; + NSMenuItem *groupItem = [[NSMenuItem alloc] initWithTitle:title action:@selector(groupMultiSelectedTabs:) keyEquivalent:@""]; + groupItem.target = self; + groupItem.representedObject = [_multiSelectedTabViewItems copy]; + [menu addItem:groupItem]; + } + + if (!item) { // when the "LSUIElement hack" (issue #954) is enabled, the menu bar is inaccessible, // so show it as a context menu when right-clicking empty tabBar region if ([[[NSBundle mainBundle] infoDictionary] objectForKey:@"LSUIElement"]) { @@ -2233,7 +2593,10 @@ - (void)draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)aPoin _haveInitialDragLocation = NO; if (operation != NSDragOperationNone) { - [self removeTabForCell:[[PSMTabDragAssistant sharedDragAssistant] draggedCell]]; + PSMTabBarCell *cell = [[PSMTabDragAssistant sharedDragAssistant] draggedCell]; + if (cell) { + [self removeTabForCell:cell]; + } [[PSMTabDragAssistant sharedDragAssistant] finishDrag]; } else { [[PSMTabDragAssistant sharedDragAssistant] draggedImageEndedAt:aPoint operation:operation]; @@ -2266,7 +2629,83 @@ - (void)closeTabClick:(id)sender button:(int)button { } } +- (void)_updateGroupSelectionButton { + const NSUInteger count = _multiSelectedTabViewItems.count; + if (count < 2) { + _groupSelectionButton.hidden = YES; + return; + } + if (!_groupSelectionButton) { + _groupSelectionButton = [[PSMGroupPillButton alloc] init]; + _groupSelectionButton.target = self; + _groupSelectionButton.action = @selector(_groupSelectionButtonClicked:); + [self addSubview:_groupSelectionButton]; + } + _groupSelectionButton.title = [NSString stringWithFormat:@"Group (%lu)", (unsigned long)count]; + NSSize s = _groupSelectionButton.intrinsicContentSize; + const CGFloat margin = 6.0; + NSRect b = self.bounds; + _groupSelectionButton.frame = NSMakeRect(NSMaxX(b) - s.width - margin, + NSMidY(b) - s.height / 2.0, + s.width, s.height); + _groupSelectionButton.hidden = NO; + [_groupSelectionButton setNeedsDisplay:YES]; +} + +- (void)_groupSelectionButtonClicked:(id)sender { + if (_multiSelectedTabViewItems.count < 2) { return; } + NSSet *items = [_multiSelectedTabViewItems copy]; + if ([[self delegate] respondsToSelector:@selector(tabView:groupTabViewItems:)]) { + [[self delegate] tabView:_tabView groupTabViewItems:[items allObjects]]; + } + [self clearMultiSelection]; +} + +- (void)clearMultiSelection { + for (PSMTabBarCell *cell in _cells) { + cell.isMultiSelected = NO; + } + [_multiSelectedTabViewItems removeAllObjects]; + _groupSelectionButton.hidden = YES; + [self setNeedsDisplay:YES]; +} + +- (void)groupMultiSelectedTabs:(NSMenuItem *)sender { + NSSet *items = sender.representedObject; + if (!items || items.count < 2) { return; } + if ([[self delegate] respondsToSelector:@selector(tabView:groupTabViewItems:)]) { + [[self delegate] tabView:_tabView groupTabViewItems:[items allObjects]]; + } + [self clearMultiSelection]; +} + - (void)tabClick:(id)sender { + PSMTabBarCell *cell = sender; + NSTabViewItem *clickedItem = [cell representedObject]; + if ([[clickedItem identifier] isKindOfClass:[iTermTabGroup class]]) { + if ([[self delegate] respondsToSelector:@selector(tabView:didClickGroupHeaderTabViewItem:)]) { + [[self delegate] tabView:_tabView didClickGroupHeaderTabViewItem:clickedItem]; + } + return; + } + + const BOOL cmdHeld = ([NSApp currentEvent].modifierFlags & NSEventModifierFlagCommand) != 0; + if (cmdHeld) { + if (!_multiSelectedTabViewItems) { + _multiSelectedTabViewItems = [[NSMutableSet alloc] init]; + } + if ([_multiSelectedTabViewItems containsObject:clickedItem]) { + [_multiSelectedTabViewItems removeObject:clickedItem]; + cell.isMultiSelected = NO; + } else { + [_multiSelectedTabViewItems addObject:clickedItem]; + cell.isMultiSelected = YES; + } + [self _updateGroupSelectionButton]; + return; + } + + [self clearMultiSelection]; if ([sender representedObject]) { [_tabView selectTabViewItem:[sender representedObject]]; [self update]; @@ -2274,6 +2713,13 @@ - (void)tabClick:(id)sender { } - (void)tabDoubleClick:(id)sender { + NSTabViewItem *clickedItem = [sender representedObject]; + if ([[clickedItem identifier] isKindOfClass:[iTermTabGroup class]]) { + if ([[self delegate] respondsToSelector:@selector(tabView:doubleClickGroupHeaderTabViewItem:)]) { + [[self delegate] tabView:_tabView doubleClickGroupHeaderTabViewItem:clickedItem]; + } + return; + } if ([[self delegate] respondsToSelector:@selector(tabView:doubleClickTabViewItem:)]) { [[self delegate] tabView:[self tabView] doubleClickTabViewItem:[sender representedObject]]; } @@ -2498,6 +2944,9 @@ - (void)tabView:(NSTabView *)tabView doubleClickTabViewItem:(NSTabViewItem *)tab } - (BOOL)tabView:(NSTabView *)aTabView shouldSelectTabViewItem:(NSTabViewItem *)tabViewItem { + if ([[tabViewItem identifier] isKindOfClass:[iTermTabGroup class]]) { + return NO; + } if ([[self delegate] respondsToSelector:@selector(tabView:shouldSelectTabViewItem:)]) { return (BOOL)[[self delegate] tabView:aTabView shouldSelectTabViewItem:tabViewItem]; } else { @@ -2566,8 +3015,12 @@ - (NSString *)view:(NSView *)view stringForToolTip:(NSToolTipTag)tag point:(NSPo [self updateTooltipAppearance]; }); + PSMTabBarCell *cell = [self cellForPoint:point cellFrame:nil]; + if (cell.isGroupHeader) { + return @""; + } if ([[self delegate] respondsToSelector:@selector(tabView:toolTipForTabViewItem:)]) { - return [[self delegate] tabView:[self tabView] toolTipForTabViewItem:[[self cellForPoint:point cellFrame:nil] representedObject]]; + return [[self delegate] tabView:[self tabView] toolTipForTabViewItem:[cell representedObject]]; } return @""; } diff --git a/ThirdParty/PSMTabBarControl/source/PSMTahoeTabStyle.swift b/ThirdParty/PSMTabBarControl/source/PSMTahoeTabStyle.swift index 168a28f844..a502a1848d 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTahoeTabStyle.swift +++ b/ThirdParty/PSMTabBarControl/source/PSMTahoeTabStyle.swift @@ -229,7 +229,7 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { } @objc func closeButtonRect(forTabCell cell: PSMTabBarCell) -> NSRect { - if cell.isPinned { + if cell.isPinned || cell.isGroupHeader { return NSZeroRect } let cellFrame = cell.frame @@ -317,6 +317,17 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { if cell.isPinned { return Float(tabBar?.pinnedTabWidth ?? 0) } + if cell.isGroupHeader { + guard let displayName = groupHeaderDisplayName(for: cell) else { + return 40.0 + } + let nameAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .semibold) + ] + let nameWidth = ceil(displayName.size(withAttributes: nameAttributes).width) + let horizontalPadding = 52.0 + return Float(ceil(nameWidth + horizontalPadding)) + } return Float(ceil(widthOfLeftMatterInCell(cell) + widthOfAttributedStringInCell(cell) + widthOfRightMatterInCell(cell))) @@ -488,10 +499,13 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { } @objc func drawTabCell(_ cell: PSMTabBarCell, highlightAmount: CGFloat) { + if cell.isAnimatingCollapse { + return + } let horizontal = (_orientation == .horizontalOrientation) let isFirst = (cell == tabBar?.cells().firstObject as? PSMTabBarCell) let isLast = (cell == tabBar?.cells().lastObject as? PSMTabBarCell) - + if tabBar?.window?.isKeyWindow == true { if cell.state == .on { drawDropShadow(cell: cell) @@ -504,15 +518,123 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { withTabColor: cell.tabColor, isFirst: isFirst, isLast: isLast, - highlightAmount: highlightAmount, + highlightAmount: cell.isGroupHeader ? 0.0 : highlightAmount, isHighlighted: cell.isHighlighted) - drawInterior(with: cell, inView: cell.controlView, highlightAmount: highlightAmount) - + if cell.isGroupHeader, cell.groupColor != nil { + drawGroupHeaderDecorations(cell) + } else { + if cell.isGroupMember, cell.groupColor != nil { + drawGroupMemberDecoration(cell) + } + drawInterior(with: cell, inView: cell.controlView, highlightAmount: highlightAmount) + } + if PSMTahoeTabStyleDebuggingEnabled { NSColor.red.set() cell.frame.frame(withWidth: 0.5) } } + + private func drawGroupHeaderDecorations(_ cell: PSMTabBarCell) { + guard let groupColor = cell.groupColor else { + return + } + let cellFrame = cell.frame + let effectiveHighlight = max(cell.highlightAmount, cell.isGroupActive ? 0.5 : 0.0) + + guard let displayName = groupHeaderDisplayName(for: cell) else { + let dotDiameter = 16.0 + let dotRect = NSRect(x: cellFrame.midX - dotDiameter / 2.0, + y: cellFrame.midY - dotDiameter / 2.0, + width: dotDiameter, + height: dotDiameter) + if effectiveHighlight > 0.001 { + let haloDiameter = dotDiameter + 8.0 + let haloRect = NSRect(x: cellFrame.midX - haloDiameter / 2.0, + y: cellFrame.midY - haloDiameter / 2.0, + width: haloDiameter, + height: haloDiameter) + groupColor.withAlphaComponent(effectiveHighlight * 0.3).set() + NSBezierPath(ovalIn: haloRect).fill() + } + groupColor.set() + NSBezierPath(ovalIn: dotRect).fill() + return + } + + let horizontalMargin = 6.0 + let verticalMargin = 4.0 + var pillRect = backgroundRect(for: cellFrame).insetBy(dx: horizontalMargin, dy: verticalMargin) + if _orientation == .verticalOrientation { + // Vertical cells span the full bar width; hug the name instead of stretching. + let nameAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .semibold) + ] + let nameWidth = ceil(displayName.size(withAttributes: nameAttributes).width) + let pillWidth = min(pillRect.width, nameWidth + horizontalMargin * 5) + pillRect = NSRect(x: cellFrame.midX - pillWidth / 2.0, + y: pillRect.minY, + width: pillWidth, + height: pillRect.height) + } + let cornerRadius = pillRect.height / 2.0 + let pill = NSBezierPath(roundedRect: pillRect, xRadius: cornerRadius, yRadius: cornerRadius) + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + (groupColor.usingColorSpace(.sRGB) ?? groupColor).getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let lightGroupColor = (0.299 * red + 0.587 * green + 0.114 * blue) > 0.55 + + groupColor.set() + pill.fill() + + // Active state (a member tab is currently selected): persistent inner border + if cell.isGroupActive { + NSColor(white: lightGroupColor ? 0.0 : 1.0, alpha: 0.4).set() + pill.lineWidth = 1.5 + pill.stroke() + } + + // Hover: contrasting overlay that fades in with mouse position + if cell.highlightAmount > 0.001 { + NSColor(white: lightGroupColor ? 0.0 : 1.0, alpha: cell.highlightAmount * 0.25).set() + pill.fill() + } + + let textColor = lightGroupColor ? NSColor(white: 0.1, alpha: 1.0) : .white + let nameAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .semibold), + .foregroundColor: textColor + ] + let nameSize = displayName.size(withAttributes: nameAttributes) + displayName.draw(at: NSPoint(x: pillRect.midX - nameSize.width / 2.0, + y: pillRect.midY - nameSize.height / 2.0), + withAttributes: nameAttributes) + } + + private func drawGroupMemberDecoration(_ cell: PSMTabBarCell) { + guard let groupColor = cell.groupColor else { + return + } + let radius = max(0, barRadius - 2.5) + let rect = backgroundRect(for: cell.frame) + if cell.state == .on { + // Selected member: thin group-colour ring around the regular pill. + let ring = NSBezierPath(roundedRect: rect.insetBy(dx: 0.75, dy: 0.75), + xRadius: max(0, radius - 0.75), + yRadius: max(0, radius - 0.75)) + groupColor.set() + ring.lineWidth = 1.5 + ring.stroke() + } else { + // Unselected member: subtle wash of the group colour across the pill. + let pill = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + groupColor.withAlphaComponent(0.14).set() + pill.fill() + } + } private func drawDropShadow(cell: PSMTabBarCell) { let scale = cell.controlView?.window?.backingScaleFactor ?? 2.0 @@ -664,6 +786,9 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { let cell = drawableCells.remove(at: i) drawableCells.append(cell) } + // Soft group wash sits behind the tabs so it never competes with the + // per-tab loading bar (which owns the top edge). + drawGroupContainerFills(bar: bar) for cell in drawableCells { cell.draw(withFrame: cell.frame, in: bar) } @@ -685,12 +810,76 @@ class PSMTahoeTabStyle: NSObject, PSMTabStyle { for i in 0..<(sorted.count - 1) { drawDivider(betweenCell: sorted[i], andCell: sorted[i + 1]) } + drawGroupContainerBorders(bar: bar) if let selectedCell = drawableCells.first, selectedCell.state == .on { selectedCell.drawPostHocDecorations(onSelectedCell: selectedCell, tabBarControl: bar) } } } + // A group reads as a Chrome-style tinted container — a region, not a line — + // so it can never be confused with or collide against the per-tab loading + // bar. Each contiguous run of (header + its member cells) yields one rounded + // container: a soft fill drawn behind the tabs and a crisp outline over them. + private func groupRuns(bar: PSMTabBarControl) -> [(rect: NSRect, color: NSColor)] { + guard _orientation == .horizontalOrientation, + let cells = bar.cells() as? [PSMTabBarCell] else { + return [] + } + var runs: [(rect: NSRect, color: NSColor)] = [] + var i = 0 + while i < cells.count { + let header = cells[i] + guard header.isGroupHeader, let groupColor = header.groupColor, !header.isInOverflowMenu else { + i += 1 + continue + } + var runRect = header.frame + var j = i + 1 + while j < cells.count { + let member = cells[j] + if !member.isGroupMember || member.isInOverflowMenu || member.groupColor != groupColor { + break + } + runRect = runRect.union(member.frame) + j += 1 + } + runs.append((runRect, groupColor)) + i = j + } + return runs + } + + private func groupContainerPath(for runRect: NSRect) -> NSBezierPath { + let containerRect = runRect.insetBy(dx: 2, dy: 3) + let radius = 6.0 + return NSBezierPath(roundedRect: containerRect, xRadius: radius, yRadius: radius) + } + + private func drawGroupContainerFills(bar: PSMTabBarControl) { + for run in groupRuns(bar: bar) { + run.color.withAlphaComponent(0.15).setFill() + groupContainerPath(for: run.rect).fill() + } + } + + private func drawGroupContainerBorders(bar: PSMTabBarControl) { + for run in groupRuns(bar: bar) { + let path = groupContainerPath(for: run.rect) + path.lineWidth = 1.0 + run.color.withAlphaComponent(0.55).setStroke() + path.stroke() + } + } + + private func groupHeaderDisplayName(for cell: PSMTabBarCell) -> String? { + let name = cell.groupName ?? "" + if cell.isGroupCollapsed, cell.groupMemberCount > 0 { + return name.isEmpty ? "\(cell.groupMemberCount)" : "\(name) (\(cell.groupMemberCount))" + } + return name.isEmpty ? nil : name + } + var dividerColor: NSColor { NSColor(displayP3Red: 0.82, green: 0.82, blue: 0.82, alpha: 1.0) } diff --git a/ThirdParty/PSMTabBarControl/source/PSMYosemiteTabStyle.m b/ThirdParty/PSMTabBarControl/source/PSMYosemiteTabStyle.m index 9796841e44..447a9346b7 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMYosemiteTabStyle.m +++ b/ThirdParty/PSMTabBarControl/source/PSMYosemiteTabStyle.m @@ -183,7 +183,7 @@ - (NSRect)dragRectForTabCell:(PSMTabBarCell *)cell } - (NSRect)closeButtonRectForTabCell:(PSMTabBarCell *)cell { - if (cell.isPinned) { + if (cell.isPinned || cell.isGroupHeader) { return NSZeroRect; } NSRect cellFrame = [cell frame]; @@ -424,6 +424,16 @@ - (float)desiredWidthOfTabCell:(PSMTabBarCell *)cell { if (cell.isPinned) { return self.tabBar.pinnedTabWidth; } + if (cell.isGroupHeader) { + NSString *displayName = [self groupHeaderDisplayNameForCell:cell]; + if (!displayName.length) { + return 40.0; + } + NSDictionary *nameAttrs = @{ NSFontAttributeName: [NSFont systemFontOfSize:self.fontSize weight:NSFontWeightSemibold] }; + CGFloat nameWidth = ceil([displayName sizeWithAttributes:nameAttrs].width); + const CGFloat hPad = 52.0; + return ceil(nameWidth + hPad); + } return ceil([self widthOfLeftMatterInCell:cell] + [self widthOfAttributedStringInCell:cell] + [self widthOfRightMatterInCell:cell]); @@ -519,7 +529,7 @@ - (PSMCachedTitleInputs *)cachedTitleInputsForTabCell:(PSMTabBarCell *)cell { PSMCachedTitleInputs *inputs = [[PSMCachedTitleInputs alloc] initWithTitle:title truncationStyle:cell.truncationStyle color:[self textColorForCell:cell] - graphic:[(id)[[cell representedObject] identifier] psmTabGraphic] + graphic:cell.isGroupHeader ? nil : [(id)[[cell representedObject] identifier] psmTabGraphic] orientation:_orientation fontSize:self.fontSize parseHTML:parseHTML @@ -802,6 +812,9 @@ - (NSEdgeInsets)insetsForTabBarDividers { } - (void)drawTabCell:(PSMTabBarCell *)cell highlightAmount:(CGFloat)highlightAmount { + if (cell.isAnimatingCollapse) { + return; + } // TODO: Test hidden control, whose height is less than 2. Maybe it happens while dragging? [self drawCellBackgroundAndFrameHorizontallyOriented:(_orientation == PSMTabBarHorizontalOrientation) inRect:cell.frame @@ -809,8 +822,121 @@ - (void)drawTabCell:(PSMTabBarCell *)cell highlightAmount:(CGFloat)highlightAmou withTabColor:[cell tabColor] isFirst:cell == _tabBar.cells.firstObject isLast:cell == _tabBar.cells.lastObject - highlightAmount:highlightAmount]; - [self drawInteriorWithTabCell:cell inView:[cell controlView] highlightAmount:highlightAmount]; + highlightAmount:cell.isGroupHeader ? 0.0 : highlightAmount]; + if (cell.isGroupHeader && cell.groupColor) { + [self drawGroupHeaderDecorations:cell]; + } else { + if (cell.isGroupMember && cell.groupColor) { + [self drawGroupMemberDecoration:cell]; + } + [self drawInteriorWithTabCell:cell inView:[cell controlView] highlightAmount:highlightAmount]; + } +} + +- (NSString *)groupHeaderDisplayNameForCell:(PSMTabBarCell *)cell { + if (cell.isGroupCollapsed && cell.groupMemberCount > 0) { + if (cell.groupName.length) { + return [NSString stringWithFormat:@"%@ (%@)", cell.groupName, @(cell.groupMemberCount)]; + } + return [@(cell.groupMemberCount) stringValue]; + } + return cell.groupName ?: @""; +} + +- (void)drawGroupHeaderDecorations:(PSMTabBarCell *)cell { + NSRect cellFrame = cell.frame; + NSString *displayName = [self groupHeaderDisplayNameForCell:cell]; + BOOL hasName = displayName.length > 0; + const CGFloat effectiveHighlight = MAX(cell.highlightAmount, cell.isGroupActive ? 0.5 : 0.0); + + const CGFloat hMargin = 10.0; + const CGFloat vMargin = 5.0; + const CGFloat cornerRadius = 4.0; + + NSColor *srgb = [cell.groupColor colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + CGFloat r, g, b, a; + [srgb getRed:&r green:&g blue:&b alpha:&a]; + const BOOL lightGroupColor = (0.299 * r + 0.587 * g + 0.114 * b) > 0.55; + + if (!hasName) { + const CGFloat dotDiameter = 16.0; + NSRect dotRect = NSMakeRect(NSMidX(cellFrame) - dotDiameter / 2.0, + NSMidY(cellFrame) - dotDiameter / 2.0, + dotDiameter, dotDiameter); + if (effectiveHighlight > 0.001) { + const CGFloat haloDiameter = dotDiameter + 8.0; + NSRect haloRect = NSMakeRect(NSMidX(cellFrame) - haloDiameter / 2.0, + NSMidY(cellFrame) - haloDiameter / 2.0, + haloDiameter, haloDiameter); + [[cell.groupColor colorWithAlphaComponent:effectiveHighlight * 0.3] set]; + [[NSBezierPath bezierPathWithOvalInRect:haloRect] fill]; + } + [cell.groupColor set]; + [[NSBezierPath bezierPathWithOvalInRect:dotRect] fill]; + return; + } + + NSRect pillRect = NSMakeRect(cellFrame.origin.x + hMargin, + cellFrame.origin.y + vMargin, + cellFrame.size.width - hMargin * 2, + cellFrame.size.height - vMargin * 2); + if (_orientation == PSMTabBarVerticalOrientation) { + // Vertical cells span the full bar width; hug the name instead of stretching. + NSDictionary *measureAttrs = @{ NSFontAttributeName: [NSFont systemFontOfSize:self.fontSize + weight:NSFontWeightSemibold] }; + const CGFloat nameWidth = ceil([displayName sizeWithAttributes:measureAttrs].width); + const CGFloat pillWidth = MIN(NSWidth(pillRect), nameWidth + hMargin * 3); + pillRect = NSMakeRect(NSMidX(cellFrame) - pillWidth / 2.0, + pillRect.origin.y, + pillWidth, + pillRect.size.height); + } + NSBezierPath *outerPill = [NSBezierPath bezierPathWithRoundedRect:pillRect + xRadius:cornerRadius + yRadius:cornerRadius]; + + [cell.groupColor set]; + [outerPill fill]; + + // Active state (a member tab is currently selected): persistent inner border + if (cell.isGroupActive) { + [[NSColor colorWithWhite:lightGroupColor ? 0.0 : 1.0 alpha:0.4] set]; + [outerPill setLineWidth:1.5]; + [outerPill stroke]; + } + + // Hover: contrasting overlay that fades in with mouse position + if (cell.highlightAmount > 0.001) { + [[NSColor colorWithWhite:lightGroupColor ? 0.0 : 1.0 alpha:cell.highlightAmount * 0.25] set]; + [outerPill fill]; + } + + NSColor *textColor = lightGroupColor ? [NSColor colorWithWhite:0.1 alpha:1.0] + : [NSColor whiteColor]; + + NSDictionary *nameAttrs = @{ + NSFontAttributeName: [NSFont systemFontOfSize:self.fontSize weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: textColor + }; + NSSize nameSize = [displayName sizeWithAttributes:nameAttrs]; + [displayName drawAtPoint:NSMakePoint(NSMidX(pillRect) - nameSize.width / 2.0, + NSMidY(pillRect) - nameSize.height / 2.0) + withAttributes:nameAttrs]; +} + +- (void)drawGroupMemberDecoration:(PSMTabBarCell *)cell { + NSRect cellFrame = cell.frame; + if (cell.state == NSControlStateValueOn) { + // Selected member: thin group-colour inner border around the tab. + NSBezierPath *ring = [NSBezierPath bezierPathWithRect:NSInsetRect(cellFrame, 1.0, 1.0)]; + [cell.groupColor set]; + [ring setLineWidth:1.5]; + [ring stroke]; + } else { + // Unselected member: subtle wash of the group colour across the tab. + [[cell.groupColor colorWithAlphaComponent:0.14] set]; + NSRectFillUsingOperation(cellFrame, NSCompositingOperationSourceOver); + } } - (CGFloat)tabColorBrightness:(PSMTabBarCell *)cell { @@ -1325,10 +1451,20 @@ - (void)drawTabBar:(PSMTabBarControl *)bar for (PSMTabBarCell *cell in [bar cells]) { if (![cell isInOverflowMenu] && NSIntersectsRect(NSInsetRect([cell frame], -1, -1), clipRect)) { if (cell.state == stateToDraw) { - [cell drawWithFrame:[cell frame] inView:bar]; + CGContextRef ctx = [NSGraphicsContext currentContext].CGContext; + const NSRect drawFrame = cell.frame; + [cell drawWithFrame:drawFrame inView:bar]; if ([self shouldDrawTopLineSelected:(stateToDraw == NSControlStateValueOn) attached:attachedToTitleBar position:bar.tabLocation]) { [topLineColor set]; - NSRectFill(NSMakeRect(NSMinX(cell.frame), 0, NSWidth(cell.frame), 1)); + NSRectFill(NSMakeRect(NSMinX(drawFrame), 0, NSWidth(drawFrame), 1)); + } + if (cell.isMultiSelected) { + CGContextSaveGState(ctx); + [[NSColor colorWithWhite:1.0 alpha:0.08] setFill]; + NSRectFillUsingOperation(drawFrame, NSCompositingOperationSourceOver); + [[NSColor colorWithWhite:1.0 alpha:0.25] setFill]; + NSRectFillUsingOperation(NSMakeRect(NSMinX(drawFrame), NSMaxY(drawFrame) - 1.0, NSWidth(drawFrame), 1.0), NSCompositingOperationSourceOver); + CGContextRestoreGState(ctx); } if (stateToDraw == NSControlStateValueOn) { // Can quit early since only one can be selected @@ -1357,6 +1493,7 @@ - (void)drawTabBar:(PSMTabBarControl *)bar } } + - (void)drawDividerBetweenTabBarAndContent:(NSRect)rect bar:(PSMTabBarControl *)bar { if (_orientation != PSMTabBarHorizontalOrientation) { [[self bottomLineColorSelected:NO] set]; diff --git a/docs/notes-3.7.txt b/docs/notes-3.7.txt index 87d7fe550e..9ef4f732e2 100644 --- a/docs/notes-3.7.txt +++ b/docs/notes-3.7.txt @@ -9,6 +9,15 @@ to version 3.6.7 and duplicates some items in betas since then. Major New Features: +- Tabs can now be organized into groups. Select + multiple tabs with cmd-click and click the + Group button to create a named, color-coded + group. Group headers appear in the tab bar; + click one to collapse or expand its tabs. + Drag a tab into or out of a group to change + its membership. Groups are saved in window + arrangements. + - In the Jobs view (toolbelt and status bar popover) hovering a row shows the full command as a tooltip, and pressing space diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index 8974e39c55..1f6371b8af 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -961,6 +961,7 @@ 6A8ED0F641A9897F39D60FBE /* VT100ScreenState+RCDataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = DB46A60B018E4C5F6859B164 /* VT100ScreenState+RCDataSource.m */; }; 6C0FD6BA115D3C08BBE009B0 /* iTermImageComparison.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AEB275FE345E8480B41FAEF /* iTermImageComparison.m */; }; 6C1249B29C2376A669BE56BE /* iTermLayoutSpecValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BC074C860759EEDF3F98F0 /* iTermLayoutSpecValidator.swift */; }; + 6C24405FAEB62E4BB0019A8B /* iTermTabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51E1F6235B4C132F26BE5A5 /* iTermTabGroup.swift */; }; 6CE3AED77D074C569A3D6D07 /* iTermOpenDirectory.m in Sources */ = {isa = PBXBuildFile; fileRef = 53A96ECF2322040C00AEA8E0 /* iTermOpenDirectory.m */; }; 6DE326D917E8D3D7A0A037B8 /* WorkgroupChildSpawning.swift in Sources */ = {isa = PBXBuildFile; fileRef = A25EF27A7558A60A0553EDC9 /* WorkgroupChildSpawning.swift */; }; 70F805A3A750CCB540F58C9A /* MovePaneController+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D246CFCEDD7927D320BF51 /* MovePaneController+API.swift */; }; @@ -5353,6 +5354,7 @@ B2CA85E23B4CCCC3391BA025 /* NotifyingDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 877B9444B69DE32D9CF87354 /* NotifyingDictionary.swift */; }; B3E079E1F626A259EDC76153 /* PeerSessionSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3298E60BA3D410B4C3FC1A98 /* PeerSessionSettingsViewController.swift */; }; B3F134FF5266734ED326399E /* AICustomHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E6892F33EA732E982A1991D /* AICustomHeaders.swift */; }; + B5251D317DB4504A2E795AD4 /* iTermCreateTabGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2612CE8B4B477ADE822DF001 /* iTermCreateTabGroupViewController.swift */; }; B89533C4F0E3E8C415065AAC /* iTermLayoutResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D079EA4C9132883E833B9974 /* iTermLayoutResolver.swift */; }; B947C485FA069D07B72E94CC /* iTermWorkgroupToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A07263500827075018158DEC /* iTermWorkgroupToolbarItem.swift */; }; B9B050C66BE98825D6AFEE47 /* cloak-page-world.js in Resources */ = {isa = PBXBuildFile; fileRef = ED1DEABF73F84ABCF9B2FDC8 /* cloak-page-world.js */; }; @@ -5375,6 +5377,7 @@ C7085CEC5C90392327148ECF /* VT100ScreenState+RCDataSource.h in Sources */ = {isa = PBXBuildFile; fileRef = 581A5EEA45904BD841D277FA /* VT100ScreenState+RCDataSource.h */; }; C74DEE2C4422D9EDBB63D33C /* WorkgroupSessionSpawner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21F806A265725167FCCDFC1 /* WorkgroupSessionSpawner.swift */; }; C7A0BDF06AF4B83133A0C70C /* WorkgroupAnimalNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67233FA25C3C82AA22B62E01 /* WorkgroupAnimalNames.swift */; }; + CABB49C0B6774623FB175D60 /* PseudoTerminal+TabGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C66C696B4432394394E845 /* PseudoTerminal+TabGroups.swift */; }; CC1EDD90C29B2AFC43831C81 /* VT100StringConversionConfig.h in Sources */ = {isa = PBXBuildFile; fileRef = 6F87CB3CBFBF0D0E8781513C /* VT100StringConversionConfig.h */; }; CD2E8590B507B08EBD3313A3 /* iTermCompactProxyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6CB27B6647329ACD05CE222 /* iTermCompactProxyIconView.swift */; }; CF138846630690AA6A7BCFD5 /* iTermGitClient.m in Sources */ = {isa = PBXBuildFile; fileRef = A687B66925AD6FF600E0A658 /* iTermGitClient.m */; }; @@ -6220,6 +6223,7 @@ 234916CDB04523CFD1E0390B /* ClippingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClippingsView.swift; sourceTree = ""; }; 23DA26C236E4BC3ECF3CF916 /* WorkgroupModeSwitcherItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkgroupModeSwitcherItem.swift; sourceTree = ""; }; 240C63271364D969B638DA6E /* ResilientCoordinateNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ResilientCoordinateNotifications.swift; sourceTree = ""; }; + 2612CE8B4B477ADE822DF001 /* iTermCreateTabGroupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermCreateTabGroupViewController.swift; sourceTree = ""; }; 26D937108B4A3A7BC121FF1D /* iTermWorkgroupToolbarItemRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermWorkgroupToolbarItemRegistry.swift; sourceTree = ""; }; 28AF992AB875E6786421227B /* iTermScreenshotOnDemandPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermScreenshotOnDemandPreview.swift; sourceTree = ""; }; 2AEB275FE345E8480B41FAEF /* iTermImageComparison.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = iTermImageComparison.m; sourceTree = ""; }; @@ -6495,6 +6499,7 @@ 61DE2C181D86B1F93619B375 /* iTermSessionNameBuiltInFunction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermSessionNameBuiltInFunction.swift; sourceTree = ""; }; 625C956DD832F4FD0D8F9715 /* PrivateIPChecker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PrivateIPChecker.swift; sourceTree = ""; }; 6373295DEB74E8DFC995A362 /* iTermAdapterSettingsRevealHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermAdapterSettingsRevealHelper.swift; sourceTree = ""; }; + 64C66C696B4432394394E845 /* PseudoTerminal+TabGroups.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "PseudoTerminal+TabGroups.swift"; sourceTree = ""; }; 64D77A978A9DB0DB1AC8DE61 /* AILiveChatQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AILiveChatQueueTests.swift; sourceTree = ""; }; 6518C4017AD4275634FA34AA /* iTermRightGutterPanelRegistry.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = iTermRightGutterPanelRegistry.h; sourceTree = ""; }; 67233FA25C3C82AA22B62E01 /* WorkgroupAnimalNames.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkgroupAnimalNames.swift; sourceTree = ""; }; @@ -9536,6 +9541,7 @@ B231EDB860248C368A511B42 /* iTermEventTriggerEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermEventTriggerEvaluator.swift; sourceTree = ""; }; B23866DFB3D15427C93C0849 /* EnterWorkgroupTrigger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EnterWorkgroupTrigger.swift; sourceTree = ""; }; B391267E5EE9115AFB3E94B1 /* AISafetyClassifierBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISafetyClassifierBackend.swift; sourceTree = ""; }; + B51E1F6235B4C132F26BE5A5 /* iTermTabGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermTabGroup.swift; sourceTree = ""; }; B5E07413F98A022F224813BE /* AILiveDriver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AILiveDriver.swift; sourceTree = ""; }; B673BD86C08D1241D0DDA4C1 /* ExitWorkgroupBrowserTrigger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExitWorkgroupBrowserTrigger.swift; sourceTree = ""; }; B73FCEC5593D29C35827971E /* ClaudeCodeModeController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClaudeCodeModeController.swift; sourceTree = ""; }; @@ -13888,6 +13894,9 @@ 89D3137ADD4526A5A2012D83 /* iTermLayoutMutator.swift */, 77FCEB05FACE33F5F7947261 /* iTermWindowBorderView.swift */, F98ADE6D889E9911953ECB78 /* iTermGutterPanelWidths.swift */, + B51E1F6235B4C132F26BE5A5 /* iTermTabGroup.swift */, + 64C66C696B4432394394E845 /* PseudoTerminal+TabGroups.swift */, + 2612CE8B4B477ADE822DF001 /* iTermCreateTabGroupViewController.swift */, ); path = TerminalView; sourceTree = ""; @@ -21519,6 +21528,9 @@ 9A0CA7F82D9DB1934FC5BB8C /* ChatMentionPickerController.swift in Sources */, 096EE94A89ADAAA4B4A2ACF9 /* ChatIconGenerator.swift in Sources */, 815BF5D942C3EAAF56D7E2A7 /* AIPromptTemplateEvaluator.swift in Sources */, + 6C24405FAEB62E4BB0019A8B /* iTermTabGroup.swift in Sources */, + CABB49C0B6774623FB175D60 /* PseudoTerminal+TabGroups.swift in Sources */, + B5251D317DB4504A2E795AD4 /* iTermCreateTabGroupViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sources/AppKit/iTermApplicationDelegate.m b/sources/AppKit/iTermApplicationDelegate.m index a787b34d09..d6740aeb99 100644 --- a/sources/AppKit/iTermApplicationDelegate.m +++ b/sources/AppKit/iTermApplicationDelegate.m @@ -2502,7 +2502,8 @@ - (void)buildSessionSubmenu:(NSNotification *)aNotification { } for (NSTabViewItem *aTabViewItem in tabViewItemArray) { - PTYTab *aTab = [aTabViewItem identifier]; + PTYTab *aTab = aTabViewItem.it_tab; + if (!aTab) { continue; } NSMenuItem *aMenuItem; if ([aTab activeSession]) { diff --git a/sources/TerminalView/PTYTab.h b/sources/TerminalView/PTYTab.h index 8143b8bf0f..cdedc66996 100644 --- a/sources/TerminalView/PTYTab.h +++ b/sources/TerminalView/PTYTab.h @@ -353,3 +353,9 @@ extern NSString *const PTYTabArrangementOptionsPendingJumps; - (void)didSelectTab; @end + +// Tab view items normally have a PTYTab identifier, but tab-group header items do not. +@interface NSTabViewItem (iTermPTYTab) +// The owning PTYTab, or nil if this item is a tab-group header rather than a real tab. +@property (nonatomic, readonly) PTYTab *it_tab; +@end diff --git a/sources/TerminalView/PTYTab.m b/sources/TerminalView/PTYTab.m index d39e6c4f06..9eaa8030c5 100644 --- a/sources/TerminalView/PTYTab.m +++ b/sources/TerminalView/PTYTab.m @@ -7583,3 +7583,15 @@ - (NSString *)stringUniqueIdentifier { } @end + +@implementation NSTabViewItem (iTermPTYTab) + +- (PTYTab *)it_tab { + id identifier = self.identifier; + if ([identifier isKindOfClass:[PTYTab class]]) { + return identifier; + } + return nil; +} + +@end diff --git a/sources/TerminalView/PseudoTerminal+Private.h b/sources/TerminalView/PseudoTerminal+Private.h index 2b46cc8450..daaf8bc895 100644 --- a/sources/TerminalView/PseudoTerminal+Private.h +++ b/sources/TerminalView/PseudoTerminal+Private.h @@ -84,6 +84,7 @@ extern NSString *const TERMINAL_ARRANGEMENT_SIZE_LOCKED; NSArray *_screenConfigurationAtTimeOfForceFrame; BOOL _willClose; + BOOL _groupCollapseManaging; // DO NOT ACCESS DIRECTLY - USE ACCESSORS INSTEAD iTermWindowType _windowType; @@ -122,9 +123,10 @@ extern NSString *const TERMINAL_ARRANGEMENT_SIZE_LOCKED; // Used to make restoring fullscreen windows work on 10.11. @property(nonatomic, copy) void (^didEnterLionFullscreen)(PseudoTerminal *); -// This is a reference to the window's content view, here for convenience because it has -// the right type. -@property (nonatomic, readonly) __unsafe_unretained iTermRootTerminalView *contentView; +@property (nonatomic, readonly) NSMutableArray *mutableTabGroups; +@property (nonatomic, assign) BOOL groupCollapseManaging; +@property (nonatomic, readonly) NSColor *minimalTabStyleBackgroundColor; +@property (nonatomic, readonly) NSAppearance *sheetAppearance; - (void)returnTabBarToContentView; - (void)updateForTransparency:(NSWindow *)window; diff --git a/sources/TerminalView/PseudoTerminal+TabGroups.swift b/sources/TerminalView/PseudoTerminal+TabGroups.swift new file mode 100644 index 0000000000..5a4ead5f02 --- /dev/null +++ b/sources/TerminalView/PseudoTerminal+TabGroups.swift @@ -0,0 +1,564 @@ +import Cocoa + +private let iTermTabGroupsArrangementKey = "Tab Groups" + +extension PseudoTerminal { + + @objc var tabGroups: [iTermTabGroup] { + return mutableTabGroups as! [iTermTabGroup] + } + + @objc @discardableResult + func createTabGroup(name: String, color: NSColor, forTab tab: PTYTab) -> iTermTabGroup { + let group = iTermTabGroup(name: name, color: color) + mutableTabGroups.add(group) + insertHeaderItem(for: group, adjacentTo: tab) + addTab(tab, toGroup: group) + moveGroupAfterLastGroup(group) + autoManageGroupCollapseForTab(tab) + if let tabViewItem = tab.tabViewItem { + contentView.tabView.selectTabViewItem(tabViewItem) + } + return group + } + + private func insertHeaderItem(for group: iTermTabGroup, adjacentTo tab: PTYTab) { + guard let tabItem = tab.tabViewItem else { return } + let tabView = contentView.tabView + let tabIndex = tabView.indexOfTabViewItem(tabItem) + if tabIndex != NSNotFound { + tabView.insertTabViewItem(group.headerTabViewItem, at: tabIndex) + } else { + tabView.insertTabViewItem(group.headerTabViewItem, at: tabView.numberOfTabViewItems) + } + } + + private func moveGroupAfterLastGroup(_ group: iTermTabGroup) { + let tabView = contentView.tabView + let allTabs = tabs() ?? [] + + // Find the index just after the last tab that belongs to any other group. + var insertionIndex = 0 + for i in 0.. iTermTabGroup? { + guard let item = sender as? NSMenuItem, + let group = item.representedObject as? iTermTabGroup else { return nil } + return group + } + + private func unstashItems(for group: iTermTabGroup) { + let tabView = contentView.tabView + let headerIndex = tabView.indexOfTabViewItem(group.headerTabViewItem) + guard headerIndex != NSNotFound else { return } + + contentView.tabBarControl.markNextInsertions(asAnimated:group.stashedTabViewItems.count) + for (offset, item) in group.stashedTabViewItems.enumerated() { + let targetIndex = headerIndex + 1 + offset + let safeIndex = min(targetIndex, tabView.numberOfTabViewItems) + tabView.insertTabViewItem(item, at: safeIndex) + } + group.stashedTabViewItems.removeAll() + group.isCollapsed = false + } + + @objc func autoManageGroupCollapseForTab(_ tab: PTYTab) { + guard !groupCollapseManaging else { return } + groupCollapseManaging = true + defer { groupCollapseManaging = false } + let activeGroup = tabGroupForTab(tab) + for group in tabGroups { + if group === activeGroup { + if group.isCollapsed { + expandGroup(group) + } + } else if !group.isCollapsed { + collapseGroup(group) + } + } + } + + @objc func toggleCollapseGroup(_ group: iTermTabGroup) { + if group.isCollapsed { + expandGroup(group) + } else { + collapseGroup(group) + } + } + + @objc func tabGroupForTab(_ tab: PTYTab) -> iTermTabGroup? { + let tabID = Int(tab.uniqueId) + return tabGroups.first { $0.memberTabIDs.contains(tabID) } + } + + @objc func tabWillBeRemoved(_ tab: PTYTab) { + guard let group = tabGroupForTab(tab) else { return } + if group.isCollapsed { + group.stashedTabViewItems.removeAll { $0.identifier as? PTYTab === tab } + } else if currentTab() === tab { + // Pre-select another group member before NSTabView auto-selects outside the group, + // which would trigger autoManageGroupCollapseForTab and collapse the whole group. + let remainingIDs = group.memberTabIDs.filter { $0 != Int(tab.uniqueId) } + if let next = (tabs() ?? []).first(where: { remainingIDs.contains(Int($0.uniqueId)) }), + let item = next.tabViewItem { + contentView.tabView.selectTabViewItem(item) + } + } + removeTab(tab, fromGroup: group) + } + + @objc func updateTabGroupDecorations() { + let tabBar = contentView.tabBarControl + let allCells = tabBar.cells() + let selectedTabID: Int? = currentTab().map { Int($0.uniqueId) } + + for cell in allCells { + guard let psmCell = cell as? PSMTabBarCell, + let tabViewItem = psmCell.representedObject as? NSTabViewItem else { + continue + } + if let group = tabViewItem.identifier as? iTermTabGroup { + psmCell.isGroupHeader = true + psmCell.isGroupMember = false + psmCell.isGroupCollapsed = group.isCollapsed + psmCell.isGroupActive = selectedTabID.map { group.memberTabIDs.contains($0) } ?? false + psmCell.groupName = group.name + psmCell.groupColor = group.color + psmCell.groupMemberCount = group.memberTabIDs.count + } else if let tab = tabViewItem.identifier as? PTYTab, + let group = tabGroupForTab(tab) { + psmCell.isGroupHeader = false + psmCell.isGroupMember = true + psmCell.isGroupCollapsed = group.isCollapsed + psmCell.isGroupActive = false + psmCell.groupName = group.name + psmCell.groupColor = group.color + psmCell.groupMemberCount = group.memberTabIDs.count + } else { + psmCell.isGroupHeader = false + psmCell.isGroupMember = false + psmCell.isGroupCollapsed = false + psmCell.isGroupActive = false + psmCell.groupName = nil + psmCell.groupColor = nil + psmCell.groupMemberCount = 0 + } + } + tabBar.needsDisplay = true + } + + @objc func encodeTabGroupsForArrangement() -> [[String: Any]] { + let allTabs = tabs() ?? [] + return tabGroups.map { $0.toDictionary(allTabs: allTabs) } + } + + @objc func restoreTabGroupsFromArrangement(_ arrangement: [[String: Any]]) { + let allTabs = tabs() ?? [] + for dict in arrangement { + guard let group = iTermTabGroup(dictionary: dict) else { continue } + let resolvedIDs = group.memberTabIDs.compactMap { index -> Int? in + guard index >= 0, index < allTabs.count else { return nil } + return Int(allTabs[index].uniqueId) + } + guard !resolvedIDs.isEmpty else { continue } + group.memberTabIDs = resolvedIDs + + if let firstTab = allTabs.first(where: { resolvedIDs.first == Int($0.uniqueId) }), + let firstItem = firstTab.tabViewItem { + let idx = contentView.tabView.indexOfTabViewItem(firstItem) + if idx != NSNotFound { + contentView.tabView.insertTabViewItem(group.headerTabViewItem, at: idx) + } + } + + let shouldCollapse = group.isCollapsed + group.isCollapsed = false + mutableTabGroups.add(group) + if shouldCollapse { + collapseGroupSilently(group) + } + } + updateTabGroupDecorations() + } + + @objc func collapseGroupSilently(_ group: iTermTabGroup) { + let memberTabs = group.memberTabIDs.compactMap { id in + (tabs() ?? []).first { Int($0.uniqueId) == id } + } + for tab in memberTabs { + guard let item = tab.tabViewItem else { continue } + contentView.tabView.removeTabViewItem(item) + group.stashedTabViewItems.append(item) + } + group.isCollapsed = true + } + + @objc func expandGroupSilently(_ group: iTermTabGroup) { + let tabView = contentView.tabView + let headerIndex = tabView.indexOfTabViewItem(group.headerTabViewItem) + guard headerIndex != NSNotFound else { return } + contentView.tabBarControl.markNextInsertions(asAnimated:group.stashedTabViewItems.count) + for (offset, item) in group.stashedTabViewItems.enumerated() { + let targetIndex = headerIndex + 1 + offset + let safeIndex = min(targetIndex, tabView.numberOfTabViewItems) + tabView.insertTabViewItem(item, at: safeIndex) + } + group.stashedTabViewItems.removeAll() + group.isCollapsed = false + } + + @objc func showCreateTabGroupSheet(forTab tab: PTYTab) { + showCreateTabGroupSheet(forTab: tab, completion: nil) + } + + @objc(showCreateTabGroupSheetForTab:completion:) func showCreateTabGroupSheet(forTab tab: PTYTab, completion: ((iTermTabGroup) -> Void)?) { + let vc = iTermCreateTabGroupViewController() + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 340, height: 180), + styleMask: [.titled, .docModalWindow], + backing: .buffered, + defer: false) + panel.appearance = sheetAppearance + panel.contentViewController = vc + vc.completion = { [weak self, weak panel] result in + guard let self, let panel else { return } + self.window?.endSheet(panel) + guard let (name, color) = result else { return } + let group = self.createTabGroup(name: name, color: color, forTab: tab) + completion?(group) + } + window?.beginSheet(panel) { _ in } + } + + @objc func addTabToNewGroup(_ sender: Any?) { + guard let tab = tabFromSender(sender) else { return } + showCreateTabGroupSheet(forTab: tab) + } + + @objc func moveTabToGroup(_ sender: Any?) { + guard let item = sender as? NSMenuItem, + let dict = item.representedObject as? [String: AnyObject], + let tabViewItem = dict["tabViewItem"] as? NSTabViewItem, + let tab = tabViewItem.identifier as? PTYTab, + let group = dict["group"] as? iTermTabGroup else { return } + if let currentGroup = tabGroupForTab(tab), currentGroup === group { return } + if let currentGroup = tabGroupForTab(tab) { + removeTab(tab, fromGroup: currentGroup) + } + addTab(tab, toGroup: group) + autoManageGroupCollapseForTab(tab) + } + + @objc func removeTabFromGroup(_ sender: Any?) { + guard let tab = tabFromSender(sender), + let group = tabGroupForTab(tab) else { return } + removeTab(tab, fromGroup: group) + } + + private func tabFromSender(_ sender: Any?) -> PTYTab? { + guard let item = sender as? NSMenuItem, + let tabViewItem = item.representedObject as? NSTabViewItem else { return nil } + return tabViewItem.identifier as? PTYTab + } + + private func collapseGroup(_ group: iTermTabGroup) { + let allTabs = tabs() ?? [] + let memberTabs = group.memberTabIDs.compactMap { id in allTabs.first { Int($0.uniqueId) == id } } + guard !memberTabs.isEmpty else { return } + + let wasManaging = groupCollapseManaging + groupCollapseManaging = true + defer { groupCollapseManaging = wasManaging } + + if let selected = currentTab(), group.memberTabIDs.contains(Int(selected.uniqueId)) { + selectNearestTabOutside(group: group) + } + + group.isCollapsed = true + let itemsToStash = memberTabs.compactMap { $0.tabViewItem } + updateTabGroupDecorations() + + contentView.tabBarControl.beginCollapseAnimation(for: itemsToStash) { [weak self] in + guard let self else { return } + for item in itemsToStash { + self.contentView.tabView.removeTabViewItem(item) + group.stashedTabViewItems.append(item) + } + self.contentView.tabBarControl.update(true) + } + } + + private func selectNearestTabOutside(group: iTermTabGroup) { + let tabView = contentView.tabView + let headerIndex = tabView.indexOfTabViewItem(group.headerTabViewItem) + guard headerIndex != NSNotFound else { return } + let groupSize = group.memberTabIDs.count + let afterGroupIndex = headerIndex + groupSize + 1 + + for i in afterGroupIndex.. *tabGroups; +- (void)addTabToNewGroup:(id)sender; +- (void)moveTabToGroup:(id)sender; +- (void)removeTabFromGroup:(id)sender; +- (void)showCreateTabGroupSheetForTab:(PTYTab *)tab completion:(void (^)(iTermTabGroup *))completion; +- (void)addTab:(PTYTab *)tab toGroup:(iTermTabGroup *)group; +- (void)newTabInGroup:(id)sender; +- (void)closeTabGroup:(id)sender; +- (void)toggleCollapseGroupFromMenu:(id)sender; +@end + diff --git a/sources/TerminalView/PseudoTerminal.m b/sources/TerminalView/PseudoTerminal.m index f243eeb01c..b019036435 100644 --- a/sources/TerminalView/PseudoTerminal.m +++ b/sources/TerminalView/PseudoTerminal.m @@ -203,6 +203,23 @@ NSString *const TERMINAL_ARRANGEMENT_MINIATURIZED = @"miniaturized"; NSString *const TERMINAL_ARRANGEMENT_SIZE_LOCKED = @"Size Locked"; +static NSString *iTermColorNameForGroupColor(NSColor *color) { + NSColor *srgb = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + if (!srgb) return @"Group"; + CGFloat h = 0, s = 0, b = 0, a = 0; + [srgb getHue:&h saturation:&s brightness:&b alpha:&a]; + h *= 360.0; + if (s < 0.25) return @"Grey"; + if (h < 15 || h >= 345) return @"Red"; + if (h < 45) return @"Orange"; + if (h < 75) return @"Yellow"; + if (h < 150) return @"Green"; + if (h < 195) return @"Teal"; + if (h < 255) return @"Blue"; + if (h < 300) return @"Purple"; + return @"Pink"; +} + static void iTermPercentageSanitize(iTermPercentage *percentage) { if (percentage->width >= 0) { if (percentage->width <= 0 || percentage->width > 100) { @@ -439,6 +456,8 @@ @implementation PseudoTerminal { CGFloat _rightExtraWindowGrowthDelta; BOOL _excursionPrevented; + NSMutableArray *_mutableTabGroups; + } @synthesize scope = _scope; @@ -464,10 +483,15 @@ - (instancetype)initWithWindowNibName:(NSString *)windowNibName { if (self) { _automaticallySelectNewTabs = YES; self.autoCommandHistorySessionGuid = nil; + _mutableTabGroups = [[NSMutableArray alloc] init]; } return self; } +- (NSMutableArray *)mutableTabGroups { + return _mutableTabGroups; +} + - (instancetype)initWithSmartLayout:(BOOL)smartLayout windowType:(iTermWindowType)windowType savedWindowType:(iTermWindowType)savedWindowType @@ -1103,6 +1127,7 @@ - (void)dealloc { [_fullScreenEnteredSeal release]; [_windowSizeHelper release]; [_titlebarAccessoryNanny release]; + [_mutableTabGroups release]; [super dealloc]; } @@ -2263,6 +2288,7 @@ - (iTermRestorableSession *)restorableSessionForTab:(PTYTab *)aTab { // tab, and closes the window if there are no tabs left. - (void)removeTab:(PTYTab *)aTab { DLog(@"Remove tab %@", aTab); + [self tabWillBeRemoved:aTab]; if (![aTab isTmuxTab]) { // Exit synthetic sessions (filter, instant replay, screenshot mode) // so the restorable session captures live sessions that can be revived. @@ -3996,6 +4022,10 @@ - (BOOL)restoreTabsFromArrangement:(NSDictionary *)arrangement return NO; } [self updateUseTransparency]; + NSArray *groupDicts = arrangement[@"Tab Groups"]; + if (groupDicts) { + [self restoreTabGroupsFromArrangement:groupDicts]; + } return YES; } @@ -4115,34 +4145,53 @@ - (BOOL)populateArrangementWith:(iTermOr *, PTYSession *> *)ta encoder:(id)result { NSRect rect = [[self window] frame]; - return [PseudoTerminal populateArrangementWith:tabsOrSession - includingContents:includeContents - encoder:result - terminalGuid:self.terminalGuid - rect:rect - useTransparency:useTransparency_ - shouldShowToolbelt:_contentView.shouldShowToolbelt - toolbeltProportions:_contentView.toolbelt.proportions - toolbeltRestorableState:_contentView.toolbelt.restorableState - windowTitleOverrideFormat:self.scope.windowTitleOverrideFormat - hidingToolbeltShouldResizeWindow:hidingToolbeltShouldResizeWindow_ - anyFullScreen:[self anyFullScreen] - lionFullScreen:[self lionFullScreen] - oldFrame:oldFrame_ - windowType:self.windowType - savedWindowType:self.savedWindowType - percentage:_percentage - initialProfile:[self expurgatedInitialProfile] - isHotKeyWindow:self.isHotKeyWindow - hotkeyWindowType:_hotkeyWindowType - screenIndex:[[NSScreen screens] indexOfObjectIdenticalTo:[[self window] screen]] - screenNumberFromFirstProfile:_windowPositioner.screenNumberFromFirstProfile - windowSizeHelper:_windowSizeHelper - hideAfterOpening:hideAfterOpening_ - selectedTabIndex:[_contentView.tabView indexOfTabViewItem:[_contentView.tabView selectedTabViewItem]] - tab:nil - profileGuid:[[[[iTermHotKeyController sharedInstance] profileHotKeyForWindowController:self] profile] objectForKey:KEY_GUID] - isMaximized:[self isMaximized]]; + NSMutableArray *collapsedGroups = [NSMutableArray array]; + for (iTermTabGroup *group in self.tabGroups) { + if (group.isCollapsed) { + [collapsedGroups addObject:group]; + [self expandGroupSilently:group]; + } + } + + const BOOL ok = [PseudoTerminal populateArrangementWith:tabsOrSession + includingContents:includeContents + encoder:result + terminalGuid:self.terminalGuid + rect:rect + useTransparency:useTransparency_ + shouldShowToolbelt:_contentView.shouldShowToolbelt + toolbeltProportions:_contentView.toolbelt.proportions + toolbeltRestorableState:_contentView.toolbelt.restorableState + windowTitleOverrideFormat:self.scope.windowTitleOverrideFormat + hidingToolbeltShouldResizeWindow:hidingToolbeltShouldResizeWindow_ + anyFullScreen:[self anyFullScreen] + lionFullScreen:[self lionFullScreen] + oldFrame:oldFrame_ + windowType:self.windowType + savedWindowType:self.savedWindowType + percentage:_percentage + initialProfile:[self expurgatedInitialProfile] + isHotKeyWindow:self.isHotKeyWindow + hotkeyWindowType:_hotkeyWindowType + screenIndex:[[NSScreen screens] indexOfObjectIdenticalTo:[[self window] screen]] + screenNumberFromFirstProfile:_windowPositioner.screenNumberFromFirstProfile + windowSizeHelper:_windowSizeHelper + hideAfterOpening:hideAfterOpening_ + selectedTabIndex:[_contentView.tabView indexOfTabViewItem:[_contentView.tabView selectedTabViewItem]] + tab:nil + profileGuid:[[[[iTermHotKeyController sharedInstance] profileHotKeyForWindowController:self] profile] objectForKey:KEY_GUID] + isMaximized:[self isMaximized]]; + for (iTermTabGroup *group in collapsedGroups) { + [self collapseGroupSilently:group]; + } + + if (ok) { + NSArray *groupDicts = [self encodeTabGroupsForArrangement]; + if (groupDicts.count > 0) { + result[@"Tab Groups"] = groupDicts; + } + } + return ok; } + (BOOL)populateArrangementWith:(iTermOr *, PTYSession *> *)tabsOrSession @@ -5482,7 +5531,8 @@ - (NSSize)windowWillResize:(NSWindow *)sender toSize:(NSSize)proposedFrameSize { rightExtra:self.currentSession.desiredRightExtra]; // Respect minimum tab sizes. for (NSTabViewItem* tabViewItem in [_contentView.tabView tabViewItems]) { - PTYTab* theTab = [tabViewItem identifier]; + PTYTab* theTab = tabViewItem.it_tab; + if (!theTab) { continue; } NSSize minTabSize = [theTab minSize]; tabSize.width = MAX(tabSize.width, minTabSize.width); tabSize.height = MAX(tabSize.height, minTabSize.height); @@ -6892,12 +6942,15 @@ - (void)reallyDisableBlurIfNeeded { - (void)tabView:(NSTabView *)tabView didSelectTabViewItem:(NSTabViewItem *)tabViewItem { DLog(@"Did select tab view %@", tabViewItem); + PTYTab *tab = tabViewItem.it_tab; + if (!tab) { + return; + } [_contentView.tabBarControl setFlashing:YES]; if (self.autoCommandHistorySessionGuid) { [self hideAutoCommandHistory]; } - PTYTab *tab = [tabViewItem identifier]; for (PTYSession *aSession in [tab sessions]) { DLog(@"Clear new-output flag in %@", aSession); [aSession setNewOutput:NO]; @@ -6983,6 +7036,11 @@ - (void)tabView:(NSTabView *)tabView didSelectTabViewItem:(NSTabViewItem *)tabVi [_contentView setCurrentSessionAlpha:self.currentSession.textview.transparencyAlpha]; [tab didSelectTab]; [[NSNotificationCenter defaultCenter] postNotificationName:iTermSelectedTabDidChange object:tab]; + + [self autoManageGroupCollapseForTab:tab]; + if (self.tabGroups.count > 0) { + [self updateTabGroupDecorations]; + } DLog(@"Finished"); } @@ -7101,20 +7159,24 @@ - (void)saveAffinitiesLater:(PTYTab *)theTab { } - (void)tabView:(NSTabView *)tabView willRemoveTabViewItem:(NSTabViewItem *)tabViewItem { - [self saveAffinitiesLater:[tabViewItem identifier]]; + PTYTab *tab = tabViewItem.it_tab; + if (!tab) { return; } + [self saveAffinitiesLater:tab]; } - (void)tabView:(NSTabView *)tabView willAddTabViewItem:(NSTabViewItem *)tabViewItem { - + PTYTab *tab = tabViewItem.it_tab; + if (!tab) { return; } [self tabView:tabView willInsertTabViewItem:tabViewItem atIndex:[tabView numberOfTabViewItems]]; - [self saveAffinitiesLater:[tabViewItem identifier]]; + [self saveAffinitiesLater:tab]; } - (void)tabView:(NSTabView *)tabView willInsertTabViewItem:(NSTabViewItem *)tabViewItem atIndex:(int)anIndex { DLog(@"%@: tabView:%@ willInsertTabViewItem:%@ atIndex:%d", self, tabView, tabViewItem, anIndex); - PTYTab* theTab = [tabViewItem identifier]; + PTYTab* theTab = tabViewItem.it_tab; + if (!theTab) { return; } [theTab setParentWindow:self]; theTab.delegate = self; #if BETA @@ -7225,7 +7287,8 @@ - (BOOL)tabView:(NSTabView*)aTabView - (void)tabView:(NSTabView*)aTabView willDropTabViewItem:(NSTabViewItem *)tabViewItem inTabBar:(PSMTabBarControl *)aTabBarControl { - PTYTab *aTab = [tabViewItem identifier]; + PTYTab *aTab = tabViewItem.it_tab; + if (!aTab) { return; } for (PTYSession* aSession in [aTab sessions]) { [aSession setIgnoreResizeNotifications:YES]; } @@ -7260,7 +7323,8 @@ - (void)updateTabObjectCounts { - (void)tabView:(NSTabView*)aTabView didDropTabViewItem:(NSTabViewItem *)tabViewItem inTabBar:(PSMTabBarControl *)aTabBarControl { - PTYTab *aTab = [tabViewItem identifier]; + PTYTab *aTab = tabViewItem.it_tab; + if (!aTab) { return; } PseudoTerminal *term = (PseudoTerminal *)[aTabBarControl delegate]; [self didDonateTab:aTab toWindowController:term]; } @@ -7401,7 +7465,7 @@ - (void)tabViewDidChangeNumberOfTabViewItems:(NSTabView *)tabView { PtyLog(@"tabViewDidChangeNumberOfTabViewItems - calling fitWindowToTab"); NSTabViewItem *tabViewItem = [[_contentView.tabView tabViewItems] objectAtIndex:0]; - PTYTab *firstTab = [tabViewItem identifier]; + PTYTab *firstTab = tabViewItem.it_tab ?: [self tabs].firstObject; NSPoint originalOrigin = self.window.frame.origin; if (wasDraggedFromAnotherWindow_) { @@ -7583,6 +7647,7 @@ - (NSMenu *)tabView:(NSTabView *)tabView menuForTabViewItem:(NSTabViewItem *)tab NSMenu *tabMenu = [[[NSMenu alloc] initWithTitle:@""] autorelease]; NSUInteger count = 1; for (NSTabViewItem *aTabViewItem in [_contentView.tabView tabViewItems]) { + if (!aTabViewItem.it_tab) { continue; } NSString *title = [NSString stringWithFormat:@"%@ #%ld", [aTabViewItem label], (unsigned long)count++]; item = [[[NSMenuItem alloc] initWithTitle:title action:@selector(selectTab:) @@ -7694,6 +7759,43 @@ - (NSMenu *)tabView:(NSTabView *)tabView menuForTabViewItem:(NSTabViewItem *)tab [rootMenu addItem:item]; } + // tab groups + [rootMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *addToGroupItem = [[[NSMenuItem alloc] initWithTitle:@"Add to New Group\u2026" + action:@selector(addTabToNewGroup:) + keyEquivalent:@""] autorelease]; + [addToGroupItem setRepresentedObject:tabViewItem]; + [addToGroupItem setTarget:self]; + [rootMenu addItem:addToGroupItem]; + + if (self.tabGroups.count > 0) { + NSMenu *moveMenu = [[[NSMenu alloc] initWithTitle:@""] autorelease]; + for (iTermTabGroup *group in self.tabGroups) { + NSString *groupTitle = group.name.length > 0 ? group.name : iTermColorNameForGroupColor(group.color); + NSMenuItem *groupItem = [[[NSMenuItem alloc] initWithTitle:groupTitle + action:@selector(moveTabToGroup:) + keyEquivalent:@""] autorelease]; + [groupItem setRepresentedObject:@{@"tabViewItem": tabViewItem, @"group": group}]; + [groupItem setTarget:self]; + [moveMenu addItem:groupItem]; + } + NSMenuItem *moveItem = [[[NSMenuItem alloc] initWithTitle:@"Move to Group" + action:nil + keyEquivalent:@""] autorelease]; + [rootMenu addItem:moveItem]; + [rootMenu setSubmenu:moveMenu forItem:moveItem]; + } + + PTYTab *contextMenuTab = [tabViewItem identifier]; + if ([self tabGroupForTab:contextMenuTab] != nil) { + NSMenuItem *removeItem = [[[NSMenuItem alloc] initWithTitle:@"Remove from Group" + action:@selector(removeTabFromGroup:) + keyEquivalent:@""] autorelease]; + [removeItem setRepresentedObject:tabViewItem]; + [removeItem setTarget:self]; + [rootMenu addItem:removeItem]; + } + // add label [rootMenu addItem: [NSMenuItem separatorItem]]; NSSize tabColorViewSize = [ColorsMenuItemView preferredSize]; @@ -7987,8 +8089,129 @@ - (void)tabViewDoubleClickTabBar:(NSTabView *)tabView { [itad newSession:nil]; } +- (NSMenu *)tabView:(NSTabView *)tabView menuForGroupHeaderTabViewItem:(NSTabViewItem *)tabViewItem { + iTermTabGroup *group = tabViewItem.identifier; + if (![group isKindOfClass:[iTermTabGroup class]]) { + return nil; + } + NSMenu *menu = [[[NSMenu alloc] initWithTitle:@""] autorelease]; + + NSMenuItem *newTabItem = [[[NSMenuItem alloc] initWithTitle:@"New Tab in Group" + action:@selector(newTabInGroup:) + keyEquivalent:@""] autorelease]; + [newTabItem setRepresentedObject:group]; + [newTabItem setTarget:self]; + [menu addItem:newTabItem]; + + NSMenuItem *collapseItem = [[[NSMenuItem alloc] initWithTitle:group.isCollapsed ? @"Expand Group" : @"Collapse Group" + action:@selector(toggleCollapseGroupFromMenu:) + keyEquivalent:@""] autorelease]; + [collapseItem setRepresentedObject:group]; + [collapseItem setTarget:self]; + [menu addItem:collapseItem]; + + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *editItem = [[[NSMenuItem alloc] initWithTitle:@"Edit Group…" + action:@selector(showRenameGroupSheet:) + keyEquivalent:@""] autorelease]; + [editItem setRepresentedObject:group]; + [editItem setTarget:self]; + [menu addItem:editItem]; + + NSMenuItem *ungroupItem = [[[NSMenuItem alloc] initWithTitle:@"Ungroup" + action:@selector(ungroupTabs:) + keyEquivalent:@""] autorelease]; + [ungroupItem setRepresentedObject:group]; + [ungroupItem setTarget:self]; + [menu addItem:ungroupItem]; + + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *closeItem = [[[NSMenuItem alloc] initWithTitle:@"Close Group" + action:@selector(closeTabGroup:) + keyEquivalent:@""] autorelease]; + [closeItem setRepresentedObject:group]; + [closeItem setTarget:self]; + [menu addItem:closeItem]; + + return menu; +} + +- (void)tabView:(NSTabView *)tabView doubleClickGroupHeaderTabViewItem:(NSTabViewItem *)tabViewItem { + iTermTabGroup *group = tabViewItem.identifier; + if (![group isKindOfClass:[iTermTabGroup class]]) { + return; + } + // Hold the guard so that tabView:didSelectTabViewItem: doesn't run + // autoManageGroupCollapseForTab mid-operation and undo our expand. + self.groupCollapseManaging = YES; + [self toggleCollapseGroup:group]; + self.groupCollapseManaging = NO; + + if (!group.isCollapsed) { + PTYTab *firstMember = nil; + for (PTYTab *tab in [self tabs]) { + if ([group.memberTabIDs containsObject:@(tab.uniqueId)]) { + firstMember = tab; + break; + } + } + if (firstMember) { + // Collapse other groups first, then select so the selection sticks. + [self autoManageGroupCollapseForTab:firstMember]; + if (firstMember.tabViewItem) { + [_contentView.tabView selectTabViewItem:firstMember.tabViewItem]; + } + } + } +} + +- (void)tabView:(NSTabView *)tabView groupTabViewItems:(NSArray *)tabViewItems { + NSMutableArray *tabs = [NSMutableArray array]; + for (NSTabViewItem *item in tabViewItems) { + PTYTab *tab = item.it_tab; + if (tab) { + [tabs addObject:tab]; + } + } + if (tabs.count < 2) { return; } + PTYTab *firstTab = tabs.firstObject; + [self showCreateTabGroupSheetForTab:firstTab completion:^(iTermTabGroup *group) { + if (!group) { return; } + for (NSUInteger i = 1; i < tabs.count; i++) { + [self addTab:tabs[i] toGroup:group]; + } + }]; +} + +- (void)tabView:(NSTabView *)tabView willBeginDraggingGroupHeaderTabViewItem:(NSTabViewItem *)tabViewItem { + iTermTabGroup *group = tabViewItem.identifier; + if (![group isKindOfClass:[iTermTabGroup class]]) { + return; + } + if (!group.isCollapsed) { + self.groupCollapseManaging = YES; + [self collapseGroupSilently:group]; + self.groupCollapseManaging = NO; + [self updateTabGroupDecorations]; + [_contentView.tabBarControl update]; + } +} + +- (void)tabView:(NSTabView *)tabView didClickGroupHeaderTabViewItem:(NSTabViewItem *)tabViewItem { + iTermTabGroup *group = tabViewItem.identifier; + if (![group isKindOfClass:[iTermTabGroup class]]) { + return; + } + // Single click on the group chip opens the management popover (Chrome-style); + // double click toggles collapse (see doubleClickGroupHeaderTabViewItem:). + [self showManageGroupPopoverFor:group]; +} + - (void)tabView:(NSTabView *)tabView updateStateForTabViewItem:(NSTabViewItem *)tabViewItem { - PTYTab *tab = tabViewItem.identifier; + PTYTab *tab = tabViewItem.it_tab; + if (!tab) { return; } [_contentView.tabBarControl setIsProcessing:tab.isProcessing forTabWithIdentifier:tab]; [_contentView.tabBarControl setProgress:(PSMProgress)tab.progress forTabWithIdentifier:tab]; @@ -8178,6 +8401,13 @@ + (BOOL)titleBarShouldAppearTransparentForWindowType:(iTermWindowType)windowType - (void)tabsDidReorder { + PSMTabBarControl *tabBar = self.contentView.tabBarControl; + if (tabBar.lastDragWasGroupHeader) { + tabBar.lastDragWasGroupHeader = NO; + [self enforceContiguityOfAllGroups]; + } else { + [self reconcileGroupsAfterReorder]; + } TmuxController *controller = nil; NSMutableArray *windowIds = [NSMutableArray array]; @@ -9779,7 +10009,8 @@ - (NSSize)sizeOfLargestTabWithExclusion:(PseudoTerminalTabSizeExclusion)exclusio PtyLog(@"fitWindowToTabs......."); DLog(@"Finding the biggest tab:"); for (NSTabViewItem* item in [_contentView.tabView tabViewItems]) { - PTYTab* tab = [item identifier]; + PTYTab* tab = item.it_tab; + if (!tab) { continue; } switch (exclusion) { case PseudoTerminalTabSizeExclusionTmux: if (tab.isTmuxTab) { @@ -10220,7 +10451,8 @@ - (float)minWidth // Pick 400 as an absolute minimum just to be safe. This is rather arbitrary and hacky. float minWidth = 400; for (NSTabViewItem* tabViewItem in [_contentView.tabView tabViewItems]) { - PTYTab* theTab = [tabViewItem identifier]; + PTYTab* theTab = tabViewItem.it_tab; + if (!theTab) { continue; } minWidth = MAX(minWidth, [theTab minSize].width); } return minWidth; @@ -10284,7 +10516,10 @@ - (void)addTabAtAutomaticallyDeterminedLocation:(PTYTab *)tab { NSMutableArray *tabs = [NSMutableArray arrayWithCapacity:n]; for (int i = 0; i < n; ++i) { NSTabViewItem* theItem = [_contentView.tabView tabViewItemAtIndex:i]; - [tabs addObject:[theItem identifier]]; + PTYTab *tab = theItem.it_tab; + if (tab) { + [tabs addObject:tab]; + } } return tabs; } @@ -10605,7 +10840,8 @@ - (void)refreshTerminal:(NSNotification *)aNotification { // formerly countless tabs show their counts. BOOL needResize = NO; for (int i = 0; i < [_contentView.tabView numberOfTabViewItems]; ++i) { - PTYTab *aTab = [[_contentView.tabView tabViewItemAtIndex:i] identifier]; + PTYTab *aTab = [_contentView.tabView tabViewItemAtIndex:i].it_tab; + if (!aTab) { continue; } if ([aTab updatePaneTitles]) { needResize = YES; } @@ -12200,7 +12436,9 @@ - (IBAction)enableSendInputToAllTabs:(id)sender { - (void)fitTabsToWindow { PtyLog(@"fitTabsToWindow begins"); for (int i = 0; i < [_contentView.tabView numberOfTabViewItems]; ++i) { - [self fitTabToWindow:[[_contentView.tabView tabViewItemAtIndex:i] identifier]]; + PTYTab *tab = [_contentView.tabView tabViewItemAtIndex:i].it_tab; + if (!tab) { continue; } + [self fitTabToWindow:tab]; } PtyLog(@"fitTabsToWindow returns"); } @@ -12521,7 +12759,8 @@ - (long long)timestampForFraction:(float)f - (NSArray *)allSessions { NSMutableArray* result = [NSMutableArray arrayWithCapacity:[_contentView.tabView numberOfTabViewItems]]; for (NSTabViewItem* item in [_contentView.tabView tabViewItems]) { - PTYTab *tab = [item identifier]; + PTYTab *tab = item.it_tab; + if (!tab) { continue; } [result addObjectsFromArray:[tab sessions]]; } return result; @@ -13663,6 +13902,16 @@ - (NSColor *)minimalTabStyleBackgroundColor { return self.currentSession.effectiveUnprocessedBackgroundColor; } +- (NSAppearance *)sheetAppearance { + BOOL isDark; + if ((iTermPreferencesTabStyle)[iTermPreferences intForKey:kPreferenceKeyTabStyle] == TAB_STYLE_MINIMAL) { + isDark = self.minimalTabStyleBackgroundColor.isDark; + } else { + isDark = [self.window.effectiveAppearance bestMatchFromAppearancesWithNames:@[NSAppearanceNameDarkAqua, NSAppearanceNameAqua]] == NSAppearanceNameDarkAqua; + } + return [NSAppearance appearanceNamed:isDark ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua]; +} + #pragma mark - iTermBroadcastInputHelperDelegate - (NSArray *)broadcastInputHelperSessionsInCurrentTab:(iTermBroadcastInputHelper *)helper diff --git a/sources/TerminalView/iTermCreateTabGroupViewController.swift b/sources/TerminalView/iTermCreateTabGroupViewController.swift new file mode 100644 index 0000000000..151603409d --- /dev/null +++ b/sources/TerminalView/iTermCreateTabGroupViewController.swift @@ -0,0 +1,354 @@ +import Cocoa + +@objc class iTermCreateTabGroupViewController: NSViewController { + var completion: ((String, NSColor)?) -> Void = { _ in } + + private let swatchDiameter: CGFloat = 24 + private let swatchSpacing: CGFloat = 8 + + private let swatchColors: [(NSColor, String)] = [ + (NSColor(srgbRed: 0.259, green: 0.522, blue: 0.957, alpha: 1), "#4285F4"), + (NSColor(srgbRed: 0.612, green: 0.153, blue: 0.690, alpha: 1), "#9C27B0"), + (NSColor(srgbRed: 0.000, green: 0.537, blue: 0.482, alpha: 1), "#00897B"), + (NSColor(srgbRed: 0.902, green: 0.318, blue: 0.000, alpha: 1), "#E65100"), + (NSColor(srgbRed: 0.961, green: 0.498, blue: 0.090, alpha: 1), "#F57F17"), + (NSColor(srgbRed: 0.678, green: 0.078, blue: 0.341, alpha: 1), "#AD1457"), + (NSColor(srgbRed: 0.180, green: 0.490, blue: 0.196, alpha: 1), "#2E7D32"), + (NSColor(srgbRed: 0.329, green: 0.431, blue: 0.478, alpha: 1), "#546E7A"), + (NSColor(srgbRed: 0.776, green: 0.157, blue: 0.157, alpha: 1), "#C62828"), + ] + + private let customColorIndex: Int + private var selectedColorIndex: Int = 0 + private var nameField: NSTextField! + private var swatchButtons: [NSButton] = [] + private var customColorButton: NSButton! + private var customColor: NSColor + private var doneButton: NSButton! + private var ownsColorPanel = false + private let isEditMode: Bool + private let isManageMode: Bool + private let initialName: String + + // Manage-mode (popover) callbacks. Name and colour apply live as the user + // edits; the action closures run the corresponding group operation. All are + // ignored in the create/edit sheet, which commits via `completion` instead. + var onNameChange: ((String) -> Void)? + var onColorChange: ((NSColor) -> Void)? + var onNewTabInGroup: (() -> Void)? + var onUngroup: (() -> Void)? + var onCloseGroup: (() -> Void)? + + init(initialColor: NSColor? = nil, initialName: String = "", editMode: Bool = false, manageMode: Bool = false) { + customColorIndex = swatchColors.count + customColor = NSColor(srgbRed: 0.5, green: 0.5, blue: 0.5, alpha: 1) + isEditMode = editMode + isManageMode = manageMode + self.initialName = initialName + super.init(nibName: nil, bundle: nil) + guard let c = initialColor?.usingColorSpace(.sRGB) else { return } + let matchIndex = swatchColors.enumerated().first { _, pair in + guard let s = pair.0.usingColorSpace(.sRGB) else { return false } + return abs(s.redComponent - c.redComponent) < 0.01 + && abs(s.greenComponent - c.greenComponent) < 0.01 + && abs(s.blueComponent - c.blueComponent) < 0.01 + }?.offset + if let idx = matchIndex { + selectedColorIndex = idx + } else { + customColor = c + selectedColorIndex = customColorIndex + } + } + + required init?(coder: NSCoder) { + customColorIndex = swatchColors.count + customColor = NSColor(srgbRed: 0.5, green: 0.5, blue: 0.5, alpha: 1) + isEditMode = false + isManageMode = false + initialName = "" + super.init(coder: coder) + } + + deinit { + dismissColorPanel() + } + + override func loadView() { + if isManageMode { + loadManageView() + return + } + let container = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 180)) + self.view = container + + let titleLabel = NSTextField(labelWithString: isEditMode ? "Edit Tab Group" : "Create Tab Group") + titleLabel.font = NSFont.boldSystemFont(ofSize: 13) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(titleLabel) + + let nameLabel = NSTextField(labelWithString: "Name") + nameLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(nameLabel) + + nameField = NSTextField() + nameField.placeholderString = "Name this group (optional)" + nameField.stringValue = initialName + nameField.translatesAutoresizingMaskIntoConstraints = false + nameField.delegate = self + container.addSubview(nameField) + + let swatchRow = buildSwatchRow() + swatchRow.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(swatchRow) + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked(_:))) + cancelButton.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(cancelButton) + + doneButton = NSButton(title: isEditMode ? "Save" : "Done", target: self, action: #selector(doneClicked(_:))) + doneButton.bezelStyle = .rounded + doneButton.keyEquivalent = "\r" + doneButton.isEnabled = true + doneButton.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(doneButton) + + let swatchRowWidth = CGFloat(customColorIndex) * swatchDiameter + + CGFloat(customColorIndex - 1) * swatchSpacing + + swatchSpacing + swatchDiameter + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 20), + titleLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor), + + nameLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + + nameField.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), + nameField.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 8), + nameField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + + swatchRow.topAnchor.constraint(equalTo: nameField.bottomAnchor, constant: 14), + swatchRow.centerXAnchor.constraint(equalTo: container.centerXAnchor), + swatchRow.widthAnchor.constraint(equalToConstant: swatchRowWidth), + swatchRow.heightAnchor.constraint(equalToConstant: swatchDiameter), + + cancelButton.topAnchor.constraint(equalTo: swatchRow.bottomAnchor, constant: 16), + cancelButton.trailingAnchor.constraint(equalTo: doneButton.leadingAnchor, constant: -8), + cancelButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16), + + doneButton.topAnchor.constraint(equalTo: swatchRow.bottomAnchor, constant: 16), + doneButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + doneButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16), + ]) + + updateSwatchSelection() + customColorButton.layer?.backgroundColor = customColor.cgColor + } + + // Chrome-style management popover: name + colour swatches + group actions in + // a single transient surface. Name and colour apply live; actions invoke the + // corresponding closure (the presenter closes the popover). + private func loadManageView() { + let padding: CGFloat = 14 + let swatchRowWidth = CGFloat(customColorIndex) * swatchDiameter + + CGFloat(customColorIndex - 1) * swatchSpacing + + swatchSpacing + swatchDiameter + let contentWidth = swatchRowWidth + + nameField = NSTextField() + nameField.placeholderString = "Name this group (optional)" + nameField.stringValue = initialName + nameField.delegate = self + nameField.translatesAutoresizingMaskIntoConstraints = false + + let swatchRow = buildSwatchRow() + swatchRow.translatesAutoresizingMaskIntoConstraints = false + + let newTabButton = makeActionButton(title: "New tab in group", + symbol: "plus.square.on.square", + action: #selector(newTabInGroupClicked(_:))) + let ungroupButton = makeActionButton(title: "Ungroup", + symbol: "rectangle.dashed", + action: #selector(ungroupClicked(_:))) + let closeGroupButton = makeActionButton(title: "Close group", + symbol: "xmark.square", + action: #selector(closeGroupClicked(_:))) + + let topSeparator = makeSeparator() + + let stack = NSStackView(views: [ + nameField, + swatchRow, + topSeparator, + newTabButton, + ungroupButton, + closeGroupButton, + ]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.setCustomSpacing(12, after: swatchRow) + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView() + self.view = container + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: container.topAnchor, constant: padding), + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: padding), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -padding), + stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -padding), + + nameField.widthAnchor.constraint(equalToConstant: contentWidth), + swatchRow.widthAnchor.constraint(equalToConstant: swatchRowWidth), + swatchRow.heightAnchor.constraint(equalToConstant: swatchDiameter), + topSeparator.widthAnchor.constraint(equalToConstant: contentWidth), + ]) + + updateSwatchSelection() + customColorButton.layer?.backgroundColor = customColor.cgColor + container.layoutSubtreeIfNeeded() + preferredContentSize = container.fittingSize + } + + private func makeActionButton(title: String, symbol: String, action: Selector) -> NSButton { + let button = NSButton(title: " \(title)", target: self, action: action) + button.isBordered = false + button.bezelStyle = .regularSquare + button.alignment = .left + button.imagePosition = .imageLeading + button.contentTintColor = .labelColor + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + button.translatesAutoresizingMaskIntoConstraints = false + return button + } + + private func makeSeparator() -> NSBox { + let box = NSBox() + box.boxType = .separator + box.translatesAutoresizingMaskIntoConstraints = false + return box + } + + @objc private func newTabInGroupClicked(_ sender: Any?) { onNewTabInGroup?() } + @objc private func ungroupClicked(_ sender: Any?) { onUngroup?() } + @objc private func closeGroupClicked(_ sender: Any?) { onCloseGroup?() } + + private func notifyColorChange() { + guard isManageMode else { return } + onColorChange?(selectedColorIndex == customColorIndex ? customColor : swatchColors[selectedColorIndex].0) + } + + private func buildSwatchRow() -> NSView { + let container = NSView() + for (index, (color, _)) in swatchColors.enumerated() { + let button = NSButton() + button.title = "" + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = swatchDiameter / 2 + button.layer?.backgroundColor = color.cgColor + button.tag = index + button.target = self + button.action = #selector(swatchClicked(_:)) + button.frame = NSRect(x: CGFloat(index) * (swatchDiameter + swatchSpacing), + y: 0, + width: swatchDiameter, + height: swatchDiameter) + container.addSubview(button) + swatchButtons.append(button) + } + + customColorButton = NSButton() + customColorButton.title = "" + customColorButton.isBordered = false + customColorButton.wantsLayer = true + customColorButton.layer?.cornerRadius = swatchDiameter / 2 + customColorButton.layer?.backgroundColor = customColor.cgColor + if let icon = NSImage(systemSymbolName: "eyedropper", accessibilityDescription: "Custom colour") { + customColorButton.image = icon + customColorButton.imagePosition = .imageOnly + customColorButton.contentTintColor = NSColor.white.withAlphaComponent(0.85) + } + customColorButton.tag = customColorIndex + customColorButton.target = self + customColorButton.action = #selector(customColorActivated(_:)) + customColorButton.frame = NSRect(x: CGFloat(customColorIndex) * (swatchDiameter + swatchSpacing), + y: 0, + width: swatchDiameter, + height: swatchDiameter) + container.addSubview(customColorButton) + return container + } + + private func updateSwatchSelection() { + for (index, button) in swatchButtons.enumerated() { + button.layer?.borderWidth = index == selectedColorIndex ? 2.5 : 0 + button.layer?.borderColor = NSColor.white.cgColor + } + customColorButton.layer?.borderWidth = selectedColorIndex == customColorIndex ? 2.5 : 0 + customColorButton.layer?.borderColor = NSColor.white.cgColor + } + + @objc private func swatchClicked(_ sender: NSButton) { + selectedColorIndex = sender.tag + updateSwatchSelection() + notifyColorChange() + } + + @objc private func customColorActivated(_ sender: NSButton) { + selectedColorIndex = customColorIndex + updateSwatchSelection() + notifyColorChange() + let panel = NSColorPanel.shared + panel.color = customColor + panel.setTarget(self) + panel.setAction(#selector(colorPanelColorChanged(_:))) + ownsColorPanel = true + panel.orderFront(nil) + } + + @objc private func colorPanelColorChanged(_ sender: NSColorPanel) { + customColor = sender.color + customColorButton.layer?.backgroundColor = customColor.cgColor + if selectedColorIndex == customColorIndex { + updateSwatchSelection() + notifyColorChange() + } + } + + @objc private func cancelClicked(_ sender: Any?) { + dismissColorPanel() + completion(nil) + } + + @objc private func doneClicked(_ sender: Any?) { + dismissColorPanel() + let name = nameField.stringValue.trimmingCharacters(in: .whitespaces) + let color: NSColor + if selectedColorIndex == customColorIndex { + color = customColor + } else { + color = swatchColors[selectedColorIndex].0 + } + completion((name, color)) + } + + private func dismissColorPanel() { + guard ownsColorPanel else { return } + ownsColorPanel = false + let panel = NSColorPanel.shared + panel.setTarget(nil) + panel.setAction(nil) + panel.orderOut(nil) + } +} + +extension iTermCreateTabGroupViewController: NSTextFieldDelegate { + func controlTextDidChange(_ obj: Notification) { + guard isManageMode else { return } + onNameChange?(nameField.stringValue.trimmingCharacters(in: .whitespaces)) + } +} diff --git a/sources/TerminalView/iTermRootTerminalView.h b/sources/TerminalView/iTermRootTerminalView.h index 757b35ecb5..107fcc2662 100644 --- a/sources/TerminalView/iTermRootTerminalView.h +++ b/sources/TerminalView/iTermRootTerminalView.h @@ -14,13 +14,15 @@ @class iTermRootTerminalView; @class iTermStatusBarViewController; @protocol iTermSwipeHandler; -@class iTermTabBarControlView; +#import "iTermTabBarControlView.h" +#import "PTYTabView.h" @protocol iTermTabBarControlViewDelegate; @class iTermToolbeltView; @protocol iTermToolbeltViewDelegate; @protocol PSMTabBarControlDelegate; @protocol PSMPUAFontProvider; -@class PTYTabView; + +NS_ASSUME_NONNULL_BEGIN @protocol iTermRootTerminalViewDelegate - (void)repositionWidgets; @@ -48,10 +50,10 @@ - (NSColor *)rootTerminalViewTabBarBackgroundColorIgnoringTabColor:(BOOL)ignoreTabColor; - (BOOL)rootTerminalViewWindowNumberLabelShouldBeVisible; - (BOOL)rootTerminalViewShouldDrawWindowTitleInPlaceOfTabBar; -- (NSImage *)rootTerminalViewCurrentTabIcon; +- (nullable NSImage *)rootTerminalViewCurrentTabIcon; - (BOOL)rootTerminalViewShouldDrawStoplightButtons; - (BOOL)rootTerminalViewShouldRevealStandardWindowButtons; -- (iTermStatusBarViewController *)rootTerminalViewSharedStatusBarViewController; +- (nullable iTermStatusBarViewController *)rootTerminalViewSharedStatusBarViewController; // Returns YES when the tab bar is a titlebar accessory in fullscreen AND the window // uses NSWindowStyleMaskFullSizeContentView, meaning the content view extends under @@ -69,7 +71,7 @@ - (NSString *)rootTerminalViewWindowSizeViewDetailString; - (void)rootTerminalViewWillLayoutSubviews; - (void)rootTerminalViewDidLayoutSubviews; -- (NSString *)rootTerminalViewCurrentTabSubtitle; +- (nullable NSString *)rootTerminalViewCurrentTabSubtitle; - (id)rootTerminalViewPUAFontProvider; @end @@ -92,7 +94,7 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; // Gray line dividing tab/title bar from content. Will be nil if a division // view isn't needed such as for fullscreen windows or windows without a // title bar (e.g., top-of-screen). -@property(nonatomic, readonly) NSView *divisionView; +@property(nonatomic, readonly, nullable) NSView *divisionView; // Toolbelt view. Goes on the right side of the terminal window, if visible. @property(nonatomic, readonly) iTermToolbeltView *toolbelt; @@ -114,10 +116,10 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; @property(nonatomic) BOOL useMetal; @property(nonatomic, readonly) BOOL tabBarControlOnLoan NS_AVAILABLE_MAC(10_14); -@property(nonatomic, strong, readonly) iTermStatusBarViewController *statusBarViewController; +@property(nonatomic, strong, readonly, nullable) iTermStatusBarViewController *statusBarViewController; @property(nonatomic, readonly) iTermImageView *backgroundImage NS_AVAILABLE_MAC(10_14); // Excludes the window number -@property(nonatomic, readonly) NSString *windowTitle; +@property(nonatomic, readonly, nullable) NSString *windowTitle; - (instancetype)initWithFrame:(NSRect)frame color:(NSColor *)color @@ -128,8 +130,8 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; - (void)updateDivisionViewAndWindowNumberLabel; // Perform a layout pass on the toolbelt, and hide/show it as needed. -- (void)updateToolbeltFrameForWindow:(NSWindow *)thisWindow; -- (void)updateToolbeltForWindow:(NSWindow *)thisWindow; +- (void)updateToolbeltFrameForWindow:(nullable NSWindow *)thisWindow; +- (void)updateToolbeltForWindow:(nullable NSWindow *)thisWindow; // TODO: Don't expose this - (void)constrainToolbeltWidth; @@ -146,8 +148,8 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; - (void)didChangeCompactness; - (void)windowTitleDidChangeTo:(NSString *)title; -- (void)windowNumberDidChangeTo:(NSNumber *)number; -- (void)setWindowTitleIcon:(NSImage *)icon; +- (void)windowNumberDidChangeTo:(nullable NSNumber *)number; +- (void)setWindowTitleIcon:(nullable NSImage *)icon; - (iTermTabBarControlView *)borrowTabBarControl NS_AVAILABLE_MAC(10_14); - (void)returnTabBarControlView:(iTermTabBarControlView *)tabBarControl NS_AVAILABLE_MAC(10_14); - (CGFloat)maximumToolbeltWidthForViewWidth:(CGFloat)viewWidth; @@ -164,3 +166,5 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; - (CGFloat)compactProxyIconWidthIncludingMargin; @end + +NS_ASSUME_NONNULL_END diff --git a/sources/TerminalView/iTermTabBarControlView.m b/sources/TerminalView/iTermTabBarControlView.m index 8b8608df8f..8e94ae83df 100644 --- a/sources/TerminalView/iTermTabBarControlView.m +++ b/sources/TerminalView/iTermTabBarControlView.m @@ -287,9 +287,10 @@ - (void)setOrientation:(PSMTabBarOrientation)orientation { - (BOOL)cellAllowsTabProgressBar:(PSMTabBarCell *)cell { NSTabViewItem *item = (NSTabViewItem *)cell.representedObject; - PTYTab *tab = item.identifier; - if (![tab isKindOfClass:[PTYTab class]]) { - return YES; + PTYTab *tab = item.it_tab; + if (!tab) { + // Group header items have no tab and never show progress. + return NO; } return tab.activeSession.view.enableProgressBars; } @@ -306,8 +307,8 @@ - (BOOL)cellShouldShowTabProgressBar:(PSMTabBarCell *)cell { - (NSString *)tabProgressBarColorSchemeForCell:(PSMTabBarCell *)cell { NSTabViewItem *item = (NSTabViewItem *)cell.representedObject; - PTYTab *tab = item.identifier; - if (![tab isKindOfClass:[PTYTab class]]) { + PTYTab *tab = item.it_tab; + if (!tab) { return iTermProgressBarColorSchemeDefault; } return tab.activeSession.view.progressBarColorScheme ?: iTermProgressBarColorSchemeDefault; diff --git a/sources/TerminalView/iTermTabGroup.swift b/sources/TerminalView/iTermTabGroup.swift new file mode 100644 index 0000000000..6dc63b8a72 --- /dev/null +++ b/sources/TerminalView/iTermTabGroup.swift @@ -0,0 +1,79 @@ +import Cocoa + +@objc class iTermTabGroup: NSObject { + @objc let identifier: String + @objc var name: String + @objc var color: NSColor + @objc var memberTabIDs: [Int] + @objc var isCollapsed: Bool + var stashedTabViewItems: [NSTabViewItem] = [] + @objc let headerTabViewItem: NSTabViewItem + + private enum CodingKey { + static let identifier = "identifier" + static let name = "name" + static let color = "color" + static let memberTabIndices = "memberTabIndices" + static let isCollapsed = "isCollapsed" + } + + @objc(groupWithName:color:) + static func group(name: String, color: NSColor) -> iTermTabGroup { + return iTermTabGroup(name: name, color: color) + } + + private static func makeHeaderItem(label: String) -> NSTabViewItem { + let item = NSTabViewItem() + item.label = label + item.view = NSView(frame: .zero) + return item + } + + @objc init(name: String, color: NSColor) { + self.identifier = UUID().uuidString + self.name = name + self.color = color + self.memberTabIDs = [] + self.isCollapsed = false + let item = iTermTabGroup.makeHeaderItem(label: name) + self.headerTabViewItem = item + super.init() + item.identifier = self + } + + @objc func toDictionary(allTabs: [PTYTab]) -> [String: Any] { + let indices = memberTabIDs.compactMap { id -> NSNumber? in + guard let index = allTabs.firstIndex(where: { Int($0.uniqueId) == id }) else { return nil } + return NSNumber(value: index) + } + var dict: [String: Any] = [ + CodingKey.identifier: identifier, + CodingKey.name: name, + CodingKey.memberTabIndices: indices, + CodingKey.isCollapsed: isCollapsed + ] + if let colorData = try? NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false) { + dict[CodingKey.color] = colorData + } + return dict + } + + @objc init?(dictionary: [String: Any]) { + guard let identifier = dictionary[CodingKey.identifier] as? String, + let name = dictionary[CodingKey.name] as? String, + let colorData = dictionary[CodingKey.color] as? Data, + let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData), + let rawIndices = dictionary[CodingKey.memberTabIndices] as? [NSNumber] else { + return nil + } + self.identifier = identifier + self.name = name + self.color = color + self.memberTabIDs = rawIndices.map { $0.intValue } + self.isCollapsed = (dictionary[CodingKey.isCollapsed] as? Bool) ?? false + let item = iTermTabGroup.makeHeaderItem(label: name) + self.headerTabViewItem = item + super.init() + item.identifier = self + } +}