diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h index 3d5a4dfce0..ec16724ba5 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.h @@ -58,6 +58,17 @@ @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) CGFloat cellSlideOffset; +@property(nonatomic, assign) BOOL isAnimatingCollapse; +@property(nonatomic, copy, nullable) NSString *groupName; +@property(nonatomic, retain, nullable) 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 7c5535a8ba..27b3e4c750 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarCell.m @@ -185,7 +185,28 @@ @implementation PSMTabBarCell { NSMutableArray *_subtitleCache; NSTrackingArea *_cellTrackingArea; NSTrackingArea *_closeButtonTrackingArea; -} + BOOL _isGroupHeader; + BOOL _isGroupMember; + BOOL _isGroupCollapsed; + BOOL _isGroupActive; + BOOL _isMultiSelected; + CGFloat _cellAlpha; + CGFloat _cellSlideOffset; + 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 cellSlideOffset = _cellSlideOffset; +@synthesize groupName = _groupName; +@synthesize groupColor = _groupColor; +@synthesize groupMemberCount = _groupMemberCount; #pragma mark - Creation/Destruction @@ -203,6 +224,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 a11b751332..aa5d781d9a 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.h @@ -155,6 +155,11 @@ extern PSMTabBarControlOptionKey PSMTabBarControlOptionPUAFontProvider; // id

*)tabViewItems; @end @@ -225,10 +230,15 @@ typedef NS_ENUM(int, PSMTabPosition) { // tab information - (NSMutableArray *)representedTabViewItems; +- (NSMutableArray *)cells; - (int)numberOfVisibleTabs; // special effects - (void)hideTabBar:(BOOL)hide animate:(BOOL)animate; +- (void)updateAnimated; +- (void)markNextInsertionsAsAnimated:(NSInteger)count; +- (void)beginCollapseAnimationForTabViewItems:(NSArray *)items completion:(void (^)(void))completion; +- (void)cancelCollapseAnimation; - (BOOL)isTabBarHidden; // internal bindings methods also used by the tab drag assistant @@ -240,6 +250,7 @@ typedef NS_ENUM(int, PSMTabPosition) { // 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:(NSColor *)aColor forTabViewItem:(NSTabViewItem *) tabViewItem; - (NSColor*)tabColorForTabViewItem:(NSTabViewItem*)tabViewItem; diff --git a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m index 2262da6357..d1c54c6e01 100644 --- a/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m +++ b/ThirdParty/PSMTabBarControl/source/PSMTabBarControl.m @@ -139,6 +139,92 @@ - (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)updateTrackingAreas { + [super updateTrackingAreas]; + if (_trackingArea) { + [self removeTrackingArea:_trackingArea]; + } + _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); +} + +- (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]; + + // Fill: transparent normally, subtle tint on hover/press. + if (_pressed) { + [[NSColor colorWithWhite:1.0 alpha:0.12] setFill]; + [pill fill]; + } else if (_hovered) { + [[NSColor colorWithWhite:1.0 alpha:0.07] setFill]; + [pill fill]; + } + + // Border. + CGFloat borderAlpha = _hovered ? 0.5 : 0.28; + [[NSColor colorWithWhite:0.85 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:1.0 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 () @end @@ -152,6 +238,20 @@ @implementation PSMTabBarControl { NSTimer *_animationTimer; float _animationDelta; + // 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; @@ -179,6 +279,10 @@ @implementation PSMTabBarControl { NSInteger _preDragSelectedTabIndex; // or NSNotFound NSMutableArray *_tooltips; NSInteger _toolTipCoalescing; + + // multi-selection (cmd+click) + NSMutableSet *_multiSelectedTabViewItems; + PSMGroupPillButton *_groupSelectionButton; } #pragma mark - @@ -365,6 +469,9 @@ - (void)setupButtons { } - (void)dealloc { + [_fadeTimer invalidate]; + [_collapseTimer invalidate]; + [_groupHeaderSingleClickTimer invalidate]; [[NSNotificationCenter defaultCenter] removeObserver:self]; // Remove bindings. @@ -690,6 +797,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]; @@ -701,6 +825,9 @@ - (void)addTabViewItem:(NSTabViewItem *)item { } - (void)removeTabForCell:(PSMTabBarCell *)cell { + if (!cell) { + return; + } // unbind [cell unbind:@"title"]; @@ -718,6 +845,7 @@ - (void)removeTabForCell:(PSMTabBarCell *)cell { // pull from collection [_cells removeObject:cell]; + [_fadingInCells removeObject:cell]; } - (void)dragDidFinish { @@ -1066,6 +1194,113 @@ - (void)update { [self update:NO]; } +- (void)updateAnimated { + [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]]; + } + 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]; + [_collapseCompletions removeAllObjects]; + for (void (^cb)(void) in completions) { cb(); } +} + +- (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]; + [_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)update:(BOOL)animate { // This method handles all of the cell layout, and is called when something changes to require // the refresh. This method is not called during drag and drop. See the PSMTabDragAssistant's @@ -1238,6 +1473,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)); } @@ -1260,11 +1497,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 { @@ -1285,8 +1525,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); @@ -1299,11 +1540,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; } @@ -1318,8 +1559,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 + @@ -1335,11 +1578,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]; } } } @@ -1369,6 +1616,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 @@ -1523,8 +1798,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]; @@ -1605,7 +1891,7 @@ - (NSMenu *)_setupCells:(NSArray *)newValues { } // next... - cellRect.origin.x += [[newValues objectAtIndex:i] floatValue] + intercellSpacing; + cellRect.origin.x += animCellWidth + intercellSpacing; } else { // set up menu items @@ -1739,14 +2025,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]; } } } @@ -1817,10 +2105,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]]; } @@ -1892,10 +2186,30 @@ - (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 and only the rename dialog fires — no toggle. + [_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; @@ -1912,12 +2226,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"]) { @@ -2115,7 +2447,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]; @@ -2148,7 +2483,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]; @@ -2156,6 +2567,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]]; } @@ -2380,6 +2798,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 { @@ -2448,8 +2869,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/PSMYosemiteTabStyle.m b/ThirdParty/PSMTabBarControl/source/PSMYosemiteTabStyle.m index 684b98d564..33aac03287 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]; @@ -416,6 +416,15 @@ - (float)desiredWidthOfTabCell:(PSMTabBarCell *)cell { if (cell.isPinned) { return self.tabBar.pinnedTabWidth; } + if (cell.isGroupHeader) { + if (!cell.groupName.length) { + return 40.0; + } + NSDictionary *nameAttrs = @{ NSFontAttributeName: [NSFont systemFontOfSize:self.fontSize weight:NSFontWeightSemibold] }; + CGFloat nameWidth = ceil([cell.groupName sizeWithAttributes:nameAttrs].width); + const CGFloat hPad = 52.0; + return ceil(nameWidth + hPad); + } return ceil([self widthOfLeftMatterInCell:cell] + [self widthOfAttributedStringInCell:cell] + [self widthOfRightMatterInCell:cell]); @@ -505,7 +514,7 @@ - (PSMCachedTitleInputs *)cachedTitleInputsForTabCell:(PSMTabBarCell *)cell { PSMCachedTitleInputs *inputs = [[PSMCachedTitleInputs alloc] initWithTitle:cell.stringValue 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 @@ -788,6 +797,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 @@ -795,8 +807,144 @@ - (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 { + [self drawInteriorWithTabCell:cell inView:[cell controlView] highlightAmount:highlightAmount]; + if (cell.isGroupMember && cell.groupColor) { + [self drawGroupMemberSidebar:cell]; + } + } +} + +- (NSGradient *)groupGradientForColor:(NSColor *)color { + NSColor *hsb = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + CGFloat h, s, b, a; + [hsb getHue:&h saturation:&s brightness:&b alpha:&a]; + const CGFloat shift = 0.08; + NSColor *start = [NSColor colorWithHue:fmod(h + shift, 1.0) + saturation:MIN(s + 0.1, 1.0) + brightness:MIN(b + 0.1, 1.0) + alpha:1.0]; + NSColor *end = [NSColor colorWithHue:fmod(h - shift + 1.0, 1.0) + saturation:MIN(s + 0.1, 1.0) + brightness:MIN(b + 0.05, 1.0) + alpha:1.0]; + return [[NSGradient alloc] initWithColors:@[start, color, end]]; +} + +- (void)drawGroupHeaderDecorations:(PSMTabBarCell *)cell { + NSRect cellFrame = cell.frame; + BOOL hasName = cell.groupName.length > 0; + BOOL neon = [iTermAdvancedSettingsModel tabGroupNeonStyle]; + 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; + + 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]; + } + NSBezierPath *dot = [NSBezierPath bezierPathWithOvalInRect:dotRect]; + if (neon) { + [[self groupGradientForColor:cell.groupColor] drawInBezierPath:dot angle:0.0]; + } else { + [cell.groupColor set]; + [dot fill]; + } + return; + } + + NSRect pillRect = NSMakeRect(cellFrame.origin.x + hMargin, + cellFrame.origin.y + vMargin, + cellFrame.size.width - hMargin * 2, + cellFrame.size.height - vMargin * 2); + NSBezierPath *outerPill = [NSBezierPath bezierPathWithRoundedRect:pillRect + xRadius:cornerRadius + yRadius:cornerRadius]; + + if (neon) { + NSGradient *gradient = [self groupGradientForColor:cell.groupColor]; + const CGFloat borderWidth = 1.5; + NSRect innerRect = NSInsetRect(pillRect, borderWidth, borderWidth); + NSBezierPath *innerPill = [NSBezierPath bezierPathWithRoundedRect:innerRect + xRadius:MAX(cornerRadius - borderWidth, 0) + yRadius:MAX(cornerRadius - borderWidth, 0)]; + CGContextRef ctx = [NSGraphicsContext currentContext].CGContext; + + // Gradient border ring only (even-odd clip punches out the interior) + CGContextSaveGState(ctx); + CGContextAddPath(ctx, outerPill.CGPath); + CGContextAddPath(ctx, innerPill.CGPath); + CGContextEOClip(ctx); + [gradient drawInBezierPath:outerPill angle:0.0]; + CGContextRestoreGState(ctx); + + // Interior gradient fill fades in on hover/active + if (effectiveHighlight > 0.001) { + CGContextSaveGState(ctx); + CGContextSetAlpha(ctx, effectiveHighlight); + [gradient drawInBezierPath:innerPill angle:0.0]; + CGContextRestoreGState(ctx); + } + } else { + // Flat: solid group colour, brightness-adaptive text + [cell.groupColor set]; + [outerPill fill]; + + // Active state (a member tab is currently selected): persistent inner border + if (cell.isGroupActive) { + [[NSColor colorWithWhite:1.0 alpha:0.4] set]; + [outerPill setLineWidth:1.5]; + [outerPill stroke]; + } + + // Hover: dark overlay that fades in with mouse position + if (cell.highlightAmount > 0.001) { + [[NSColor colorWithWhite:0.0 alpha:cell.highlightAmount * 0.25] set]; + [outerPill fill]; + } + } + + NSColor *srgb = [cell.groupColor colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + CGFloat r, g, b, a; + [srgb getRed:&r green:&g blue:&b alpha:&a]; + NSColor *textColor = neon ? [NSColor whiteColor] + : ((0.299 * r + 0.587 * g + 0.114 * b) > 0.55 + ? [NSColor colorWithWhite:0.1 alpha:1.0] + : [NSColor whiteColor]); + + NSDictionary *nameAttrs = @{ + NSFontAttributeName: [NSFont systemFontOfSize:self.fontSize weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: textColor + }; + NSSize nameSize = [cell.groupName sizeWithAttributes:nameAttrs]; + [cell.groupName drawAtPoint:NSMakePoint(NSMidX(pillRect) - nameSize.width / 2.0, + NSMidY(pillRect) - nameSize.height / 2.0) + withAttributes:nameAttrs]; +} + +- (void)drawGroupMemberSidebar:(PSMTabBarCell *)cell { + NSRect cellFrame = cell.frame; + const CGFloat borderHeight = 2.5; + NSRect borderRect = NSMakeRect(cellFrame.origin.x, + cellFrame.origin.y, + cellFrame.size.width, + borderHeight); + [cell.groupColor set]; + NSRectFill(borderRect); } - (CGFloat)tabColorBrightness:(PSMTabBarCell *)cell { @@ -1311,10 +1459,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 diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index f6eb6cffba..58484c2a23 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -1026,6 +1026,9 @@ A6047134213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.h in Headers */ = {isa = PBXBuildFile; fileRef = A6047132213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.h */; }; A6047135213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.m in Sources */ = {isa = PBXBuildFile; fileRef = A6047133213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.m */; }; A605128F2E38525800507125 /* PseudoTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A605128E2E38525500507125 /* PseudoTerminal.swift */; }; + A17714DFD103435587C08BD9 /* iTermTabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB87C502F6C487689B2097F /* iTermTabGroup.swift */; }; + 7FBC3462745243A29DDFA417 /* PseudoTerminal+TabGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEA49D22F054825ADC1D93D /* PseudoTerminal+TabGroups.swift */; }; + 80677E91B8EE4B69B110E4A7 /* iTermCreateTabGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1699941CF15B4DAF95FE803B /* iTermCreateTabGroupViewController.swift */; }; A6057C041878CD30004A60AF /* ProfileTagsView.h in Headers */ = {isa = PBXBuildFile; fileRef = A6057C021878CD30004A60AF /* ProfileTagsView.h */; }; A6057C09187A1809004A60AF /* TerminalFile.h in Headers */ = {isa = PBXBuildFile; fileRef = A6057C07187A1809004A60AF /* TerminalFile.h */; }; A6057C0E187BC4C3004A60AF /* iTermShellHistoryController.h in Headers */ = {isa = PBXBuildFile; fileRef = A6057C0C187BC4C3004A60AF /* iTermShellHistoryController.h */; }; @@ -6467,6 +6470,9 @@ A6047132213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iTermWorkingDirectoryPoller.h; sourceTree = ""; }; A6047133213D9E7B009C6C6D /* iTermWorkingDirectoryPoller.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iTermWorkingDirectoryPoller.m; sourceTree = ""; }; A605128E2E38525500507125 /* PseudoTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PseudoTerminal.swift; sourceTree = ""; }; + 5DB87C502F6C487689B2097F /* iTermTabGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTermTabGroup.swift; sourceTree = ""; }; + CDEA49D22F054825ADC1D93D /* PseudoTerminal+TabGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PseudoTerminal+TabGroups.swift"; sourceTree = ""; }; + 1699941CF15B4DAF95FE803B /* iTermCreateTabGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTermCreateTabGroupViewController.swift; sourceTree = ""; }; A6057C021878CD30004A60AF /* ProfileTagsView.h */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.h; path = ProfileTagsView.h; sourceTree = ""; tabWidth = 4; }; A6057C031878CD30004A60AF /* ProfileTagsView.m */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.objc; path = ProfileTagsView.m; sourceTree = ""; tabWidth = 4; }; A6057C07187A1809004A60AF /* TerminalFile.h */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.h; path = TerminalFile.h; sourceTree = ""; tabWidth = 4; }; @@ -11716,6 +11722,9 @@ A6EB08882F7859D1009F5D5D /* ResilientCoordinate.swift */, A68752482EE61314002EDFCB /* ArchivesMenuBuilder.swift */, A6E2A6172B86DF0E00EC6070 /* AtomicMutableArrayOfWeakObjects.swift */, + 5DB87C502F6C487689B2097F /* iTermTabGroup.swift */, + CDEA49D22F054825ADC1D93D /* PseudoTerminal+TabGroups.swift */, + 1699941CF15B4DAF95FE803B /* iTermCreateTabGroupViewController.swift */, 1D407A2414BABE8700BD5035 /* charmaps.h */, A678C50D279B3C3900C59927 /* charmaps.m */, A678C50A279B32C600C59927 /* ComplexCharRegistry.swift */, @@ -20159,6 +20168,9 @@ A62B36572D24C1A900FDF15E /* SetNamedMarkTrigger.swift in Sources */, A608F22120F07658008E8009 /* iTermImageMark.m in Sources */, A605128F2E38525800507125 /* PseudoTerminal.swift in Sources */, + A17714DFD103435587C08BD9 /* iTermTabGroup.swift in Sources */, + 7FBC3462745243A29DDFA417 /* PseudoTerminal+TabGroups.swift in Sources */, + 80677E91B8EE4B69B110E4A7 /* iTermCreateTabGroupViewController.swift in Sources */, A61A859524EFA01800B03880 /* iTermBackgroundCommandRunner.m in Sources */, A6C156FA2E6FFAE900815BC1 /* iTermLayoutReportingView.swift in Sources */, A6153D5921F4EF1A002976FC /* iTermStatusBarTightlyPackedLayoutAlgorithm.m in Sources */, diff --git a/sources/PseudoTerminal+Private.h b/sources/PseudoTerminal+Private.h index 2b46cc8450..daaf8bc895 100644 --- a/sources/PseudoTerminal+Private.h +++ b/sources/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/PseudoTerminal+TabGroups.swift b/sources/PseudoTerminal+TabGroups.swift new file mode 100644 index 0000000000..aaeb0dc0a0 --- /dev/null +++ b/sources/PseudoTerminal+TabGroups.swift @@ -0,0 +1,481 @@ +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.updateAnimated() + } + } + + 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.. *)tabs; +// Tab groups for inline tab organization. +@property(nonatomic, readonly) NSArray *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; + // Updates the window when screen parameters (number of screens, resolutions, // etc.) change. - (void)screenParametersDidChange; diff --git a/sources/PseudoTerminal.m b/sources/PseudoTerminal.m index a829e1af73..bf025c8c79 100644 --- a/sources/PseudoTerminal.m +++ b/sources/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) { @@ -432,6 +449,8 @@ @implementation PseudoTerminal { iTermIdempotentOperationJoiner *_rightExtraJoiner; BOOL _excursionPrevented; + NSMutableArray *_mutableTabGroups; + } @synthesize scope = _scope; @@ -457,10 +476,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 @@ -1099,6 +1123,7 @@ - (void)dealloc { [_fullScreenEnteredSeal release]; [_windowSizeHelper release]; [_titlebarAccessoryNanny release]; + [_mutableTabGroups release]; [super dealloc]; } @@ -2101,6 +2126,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]) { iTermRestorableSession *restorableSession = [[[iTermRestorableSession alloc] init] autorelease]; restorableSession.sessions = [aTab sessions]; @@ -3808,6 +3834,10 @@ - (BOOL)restoreTabsFromArrangement:(NSDictionary *)arrangement return NO; } [self updateUseTransparency]; + NSArray *groupDicts = arrangement[@"Tab Groups"]; + if (groupDicts) { + [self restoreTabGroupsFromArrangement:groupDicts]; + } return YES; } @@ -3927,34 +3957,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 @@ -5274,6 +5323,7 @@ - (NSSize)windowWillResize:(NSWindow *)sender toSize:(NSSize)proposedFrameSize { // Respect minimum tab sizes. for (NSTabViewItem* tabViewItem in [_contentView.tabView tabViewItems]) { PTYTab* theTab = [tabViewItem identifier]; + if (![theTab isKindOfClass:[PTYTab class]]) { continue; } NSSize minTabSize = [theTab minSize]; tabSize.width = MAX(tabSize.width, minTabSize.width); tabSize.height = MAX(tabSize.height, minTabSize.height); @@ -6671,12 +6721,15 @@ - (void)reallyDisableBlurIfNeeded { - (void)tabView:(NSTabView *)tabView didSelectTabViewItem:(NSTabViewItem *)tabViewItem { DLog(@"Did select tab view %@", tabViewItem); + PTYTab *tab = [tabViewItem identifier]; + if (![tab isKindOfClass:[PTYTab class]]) { + 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]; @@ -6761,6 +6814,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"); } @@ -6879,11 +6937,12 @@ - (void)saveAffinitiesLater:(PTYTab *)theTab { } - (void)tabView:(NSTabView *)tabView willRemoveTabViewItem:(NSTabViewItem *)tabViewItem { + if (![[tabViewItem identifier] isKindOfClass:[PTYTab class]]) { return; } [self saveAffinitiesLater:[tabViewItem identifier]]; } - (void)tabView:(NSTabView *)tabView willAddTabViewItem:(NSTabViewItem *)tabViewItem { - + if (![[tabViewItem identifier] isKindOfClass:[PTYTab class]]) { return; } [self tabView:tabView willInsertTabViewItem:tabViewItem atIndex:[tabView numberOfTabViewItems]]; [self saveAffinitiesLater:[tabViewItem identifier]]; } @@ -6893,6 +6952,7 @@ - (void)tabView:(NSTabView *)tabView atIndex:(int)anIndex { DLog(@"%@: tabView:%@ willInsertTabViewItem:%@ atIndex:%d", self, tabView, tabViewItem, anIndex); PTYTab* theTab = [tabViewItem identifier]; + if (![theTab isKindOfClass:[PTYTab class]]) { return; } [theTab setParentWindow:self]; theTab.delegate = self; #if BETA @@ -7003,6 +7063,7 @@ - (void)tabView:(NSTabView*)aTabView willDropTabViewItem:(NSTabViewItem *)tabViewItem inTabBar:(PSMTabBarControl *)aTabBarControl { PTYTab *aTab = [tabViewItem identifier]; + if (![aTab isKindOfClass:[PTYTab class]]) { return; } for (PTYSession* aSession in [aTab sessions]) { [aSession setIgnoreResizeNotifications:YES]; } @@ -7038,6 +7099,7 @@ - (void)tabView:(NSTabView*)aTabView didDropTabViewItem:(NSTabViewItem *)tabViewItem inTabBar:(PSMTabBarControl *)aTabBarControl { PTYTab *aTab = [tabViewItem identifier]; + if (![aTab isKindOfClass:[PTYTab class]]) { return; } PseudoTerminal *term = (PseudoTerminal *)[aTabBarControl delegate]; [self didDonateTab:aTab toWindowController:term]; } @@ -7174,6 +7236,9 @@ - (void)tabViewDidChangeNumberOfTabViewItems:(NSTabView *)tabView { NSTabViewItem *tabViewItem = [[_contentView.tabView tabViewItems] objectAtIndex:0]; PTYTab *firstTab = [tabViewItem identifier]; + if (![firstTab isKindOfClass:[PTYTab class]]) { + firstTab = [self tabs].firstObject; + } NSPoint originalOrigin = self.window.frame.origin; if (wasDraggedFromAnotherWindow_) { @@ -7353,6 +7418,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 identifier] isKindOfClass:[PTYTab class]]) { continue; } NSString *title = [NSString stringWithFormat:@"%@ #%ld", [aTabViewItem label], (unsigned long)count++]; item = [[[NSMenuItem alloc] initWithTitle:title action:@selector(selectTab:) @@ -7463,6 +7529,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]; @@ -7755,8 +7858,104 @@ - (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 *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]; + + return menu; +} + +- (void)tabView:(NSTabView *)tabView doubleClickGroupHeaderTabViewItem:(NSTabViewItem *)tabViewItem { + iTermTabGroup *group = tabViewItem.identifier; + if (![group isKindOfClass:[iTermTabGroup class]]) { + return; + } + NSMenuItem *item = [[[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""] autorelease]; + [item setRepresentedObject:group]; + [self showRenameGroupSheet:item]; +} + +- (void)tabView:(NSTabView *)tabView groupTabViewItems:(NSArray *)tabViewItems { + NSMutableArray *tabs = [NSMutableArray array]; + for (NSTabViewItem *item in tabViewItems) { + PTYTab *tab = item.identifier; + if ([tab isKindOfClass:[PTYTab class]]) { + [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 updateAnimated]; + } +} + +- (void)tabView:(NSTabView *)tabView didClickGroupHeaderTabViewItem:(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 updateStateForTabViewItem:(NSTabViewItem *)tabViewItem { PTYTab *tab = tabViewItem.identifier; + if (![tab isKindOfClass:[PTYTab class]]) { return; } [_contentView.tabBarControl setIsProcessing:tab.isProcessing forTabWithIdentifier:tab]; [_contentView.tabBarControl setIcon:tab.icon forTabWithIdentifier:tab]; [_contentView.tabBarControl setObjectCount:tab.objectCount forTabWithIdentifier:tab]; @@ -7909,6 +8108,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]; @@ -9501,6 +9707,7 @@ - (NSSize)sizeOfLargestTabWithExclusion:(PseudoTerminalTabSizeExclusion)exclusio DLog(@"Finding the biggest tab:"); for (NSTabViewItem* item in [_contentView.tabView tabViewItems]) { PTYTab* tab = [item identifier]; + if (![tab isKindOfClass:[PTYTab class]]) { continue; } switch (exclusion) { case PseudoTerminalTabSizeExclusionTmux: if (tab.isTmuxTab) { @@ -9942,6 +10149,7 @@ - (float)minWidth float minWidth = 400; for (NSTabViewItem* tabViewItem in [_contentView.tabView tabViewItems]) { PTYTab* theTab = [tabViewItem identifier]; + if (![theTab isKindOfClass:[PTYTab class]]) { continue; } minWidth = MAX(minWidth, [theTab minSize].width); } return minWidth; @@ -10005,7 +10213,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]]; + id identifier = [theItem identifier]; + if ([identifier isKindOfClass:[PTYTab class]]) { + [tabs addObject:identifier]; + } } return tabs; } @@ -10327,6 +10538,7 @@ - (void)refreshTerminal:(NSNotification *)aNotification { BOOL needResize = NO; for (int i = 0; i < [_contentView.tabView numberOfTabViewItems]; ++i) { PTYTab *aTab = [[_contentView.tabView tabViewItemAtIndex:i] identifier]; + if (![aTab isKindOfClass:[PTYTab class]]) { continue; } if ([aTab updatePaneTitles]) { needResize = YES; } @@ -11894,7 +12106,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] identifier]; + if (![tab isKindOfClass:[PTYTab class]]) { continue; } + [self fitTabToWindow:tab]; } PtyLog(@"fitTabsToWindow returns"); } @@ -12212,6 +12426,7 @@ - (long long)timestampForFraction:(float)f NSMutableArray* result = [NSMutableArray arrayWithCapacity:[_contentView.tabView numberOfTabViewItems]]; for (NSTabViewItem* item in [_contentView.tabView tabViewItems]) { PTYTab *tab = [item identifier]; + if (![tab isKindOfClass:[PTYTab class]]) { continue; } [result addObjectsFromArray:[tab sessions]]; } return result; @@ -13327,6 +13542,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/iTermAdvancedSettingsModel.h b/sources/iTermAdvancedSettingsModel.h index fcae39d047..6a4076e2bb 100644 --- a/sources/iTermAdvancedSettingsModel.h +++ b/sources/iTermAdvancedSettingsModel.h @@ -374,6 +374,7 @@ extern NSString *const iTermAdvancedSettingsDidChange; + (BOOL)remapModifiersWithoutEventTap; + (BOOL)rememberTmuxWindowSizes; + (BOOL)removeAddTabButton; ++ (BOOL)tabGroupNeonStyle; + (BOOL)reportOnFirstMouse; + (BOOL)restrictSemanticHistoryPrefixAndSuffixToLogicalWindow; + (BOOL)requireCmdForDraggingText; diff --git a/sources/iTermAdvancedSettingsModel.m b/sources/iTermAdvancedSettingsModel.m index 77962d0b69..31d3206abc 100644 --- a/sources/iTermAdvancedSettingsModel.m +++ b/sources/iTermAdvancedSettingsModel.m @@ -313,6 +313,7 @@ + (BOOL)settingIsDeprecated:(NSString *)name { DEFINE_BOOL(selectsTabsOnMouseDown, YES, SECTION_TABS @"Select tabs on mouse-down?\nChanging this setting will not affect existing windows."); DEFINE_FLOAT(minimalDeslectedColoredTabAlpha, 0.5, SECTION_TABS @"Alpha value for tab color for non-selected colored tabs in the Minimal theme.\nMust be between 0 and 1."); DEFINE_STRING(tabColorMenuOptions, @"#fb6b62 #f6ac47 #f0dc4f #b5d749 #5fa3f8 #c18ed9 #787878", SECTION_TABS @"Colors for tab color menu item.\nSpace delimited strings like #rrggbb or #rgb in sRGB color space. If the P3 color space is available, you can use strings like: color(p3 1 0.5 0.25)"); +DEFINE_BOOL(tabGroupNeonStyle, YES, SECTION_TABS @"Use neon gradient style for tab group labels.\nWhen enabled group labels show a gradient border with dark fill that fills on hover. When disabled they use flat solid colour fill."); DEFINE_BOOL(removeAddTabButton, NO, SECTION_TABS @"Remove the “new tab” button from horizontal tab bars?"); DEFINE_FLOAT(lightModeInactiveTabDarkness, 0.07, SECTION_TABS @"Darkness (in [0…1]) for non-selected tabs in non-Minimal theme in light mode."); DEFINE_FLOAT(darkModeInactiveTabDarkness, 0.5, SECTION_TABS @"Darkness (in [0…1]) for non-selected tabs in non-Minimal theme in dark mode."); diff --git a/sources/iTermApplicationDelegate.m b/sources/iTermApplicationDelegate.m index b2ac87a7a9..4adb71de59 100644 --- a/sources/iTermApplicationDelegate.m +++ b/sources/iTermApplicationDelegate.m @@ -2379,6 +2379,7 @@ - (void)buildSessionSubmenu:(NSNotification *)aNotification { for (NSTabViewItem *aTabViewItem in tabViewItemArray) { PTYTab *aTab = [aTabViewItem identifier]; + if (![aTab isKindOfClass:[PTYTab class]]) { continue; } NSMenuItem *aMenuItem; if ([aTab activeSession]) { diff --git a/sources/iTermCreateTabGroupViewController.swift b/sources/iTermCreateTabGroupViewController.swift new file mode 100644 index 0000000000..a42ecd2e0c --- /dev/null +++ b/sources/iTermCreateTabGroupViewController.swift @@ -0,0 +1,231 @@ +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 initialName: String + + init(initialColor: NSColor? = nil, initialName: String = "", editMode: Bool = false) { + customColorIndex = swatchColors.count + customColor = NSColor(srgbRed: 0.5, green: 0.5, blue: 0.5, alpha: 1) + isEditMode = editMode + 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 + initialName = "" + super.init(coder: coder) + } + + override func loadView() { + 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 + } + + 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() + } + + @objc private func customColorActivated(_ sender: NSButton) { + selectedColorIndex = customColorIndex + updateSwatchSelection() + 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() + } + } + + @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 {} diff --git a/sources/iTermRootTerminalView.h b/sources/iTermRootTerminalView.h index 757b35ecb5..9c28c2b21c 100644 --- a/sources/iTermRootTerminalView.h +++ b/sources/iTermRootTerminalView.h @@ -14,13 +14,13 @@ @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; @protocol iTermRootTerminalViewDelegate - (void)repositionWidgets; @@ -83,11 +83,11 @@ extern const NSInteger iTermRootTerminalViewWindowNumberLabelWidth; // The tabview occupies almost the entire window. Each tab has an identifier // which is a PTYTab. -@property(nonatomic, readonly) PTYTabView *tabView; +@property(nonatomic, readonly, nonnull) PTYTabView *tabView; // This is a sometimes-visible control that shows the tabs and lets the user // change which is visible. -@property(nonatomic, readonly) iTermTabBarControlView *tabBarControl; +@property(nonatomic, readonly, nonnull) iTermTabBarControlView *tabBarControl; // 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 diff --git a/sources/iTermTabGroup.swift b/sources/iTermTabGroup.swift new file mode 100644 index 0000000000..c6247e9ad3 --- /dev/null +++ b/sources/iTermTabGroup.swift @@ -0,0 +1,109 @@ +import Cocoa + +@objc class iTermTabGroup: NSObject, NSCoding { + @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 memberTabIDs = "memberTabIDs" + 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 + } + + required init?(coder: NSCoder) { + guard let identifier = coder.decodeObject(forKey: CodingKey.identifier) as? String, + let name = coder.decodeObject(forKey: CodingKey.name) as? String, + let colorData = coder.decodeObject(forKey: CodingKey.color) as? Data, + let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData), + let rawIDs = coder.decodeObject(forKey: CodingKey.memberTabIDs) as? [NSNumber] else { + return nil + } + self.identifier = identifier + self.name = name + self.color = color + self.memberTabIDs = rawIDs.map { $0.intValue } + self.isCollapsed = coder.decodeBool(forKey: CodingKey.isCollapsed) + let item = iTermTabGroup.makeHeaderItem(label: name) + self.headerTabViewItem = item + super.init() + item.identifier = self + } + + func encode(with coder: NSCoder) { + coder.encode(identifier, forKey: CodingKey.identifier) + coder.encode(name, forKey: CodingKey.name) + if let colorData = try? NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false) { + coder.encode(colorData, forKey: CodingKey.color) + } + coder.encode(memberTabIDs.map { NSNumber(value: $0) } as NSArray, forKey: CodingKey.memberTabIDs) + coder.encode(isCollapsed, forKey: CodingKey.isCollapsed) + } + + @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 + } +}