Two rows tab bar - #692
Conversation
|
Thanks for this, and for tackling something hard. It's clear you spent real time in the layout engine: moving the bar out of the titlebar accessory into the content view when it grows past the standard titlebar height, deferring the height recompute to avoid reentrant layout, and handling Tahoe's y-reset in My main concern is scope of testing rather than craft. A global setting that reshapes tab layout has a large test surface (themes x macOS version x pinned/overflow/add-button x feature on/off), and right now the implementation looks validated mainly on the Tahoe style on macOS 26 with a few plain tabs. Most of the issues below cluster in the configurations that path never exercises. Before this is ready I'd want to see it driven through each theme (Regular/Tahoe, Compact, Minimal), on macOS < 26 as well as 26, and with pinned tabs and the add-tab button visible. Specific findings, most important first:
The core idea and the mechanism are solid, and the hard parts are done well. It mostly needs the config matrix widened and the theme-specific pieces generalized. Happy to re-review once the other themes and the feature-off path are exercised. |
abf5517 to
060be90
Compare
|
Thanks for the rework. This round is a real step up. I re-verified every point from my first review and they are all genuinely addressed:
Vertical/sidebar tab bars are handled well and documented as a no-op ( Then I drove it through the wider theme/config matrix, and the non-default configurations still have problems. The geometry is two-row aware now, but several per-cell drawing and interaction paths still assume a single left-to-right row. Concrete bugs: 1. Minimal: the inter-row divider draws over the overflow button
2. Compact: row-1 tabs overlap the window shortcut, and row 2's left is a dead zoneIn Compact the first row has to hold the traffic lights and the window-shortcut label. Two problems compound:
Compact needs the left-chrome (traffic lights plus window shortcut) reservation and hit-testing to be made two-row aware. This is not a one-liner like the others. 3. Tahoe: spurious divider to the right of the last cell on the bottom row4. Tahoe: hovering the rightmost cell on row 2 removes a divider on row 1Both are the same root cause. In let sorted = cellsForDividers.sorted { lhs, rhs in
lhs.frame.minX < rhs.frame.minX // X only; the two rows get interleaved
}
for i in 0..<(sorted.count - 1) {
drawDivider(betweenCell: sorted[i], andCell: sorted[i + 1])
}So "adjacent" means adjacent-in-X across both rows. The rightmost row-2 cell's X-neighbor is a row-1 cell, which is why (3) draws a divider just past the last bottom-row cell and (4) suppresses a row-1 divider when you hover the row-2 cell ( Other findings from code review (not yet visible in casual testing)
One design questionThe feature reads as "two rows when enabled" but behaves as "spill to two rows only on overflow." On a wide window that means many tabs before anything changes. Worth deciding whether the setting should mean always-two-rows-when-enabled, and clarifying the description either way. Net: mechanism and the hard parts are solid, and the round-1 issues are all fixed. But Compact is not usable yet (overlap plus the hit-test dead zone), Minimal has the overflow-divider bug, and Tahoe has the two divider bugs, so I would like another pass focused on the per-cell drawing/interaction paths in Compact/Minimal/Tahoe with the overflow chevron present. Happy to re-review after that. |
Add an advanced setting, "Show tab bar on two rows" (TwoRowTabBar, off by default), that distributes horizontal tab-bar tabs across two rows once they would overflow a single row, giving each tab more room for its title. Only affects top/bottom tab bars; left/right (vertical) bars are unchanged, and the whole feature is a no-op when the setting is off. Layout: PSMTabBarControl owns the shared two-row geometry — row-count detection (horizontalRowCount), per-row width distribution (including pinned tabs and the add-tab button), and the vertical model laid out as [top][row][gap][row][bottom] (twoRowHeightForSingleRowHeight:/twoRowContentHeight/twoRowStride, one source of truth for both the style height and the window's desired height). Per-row first/last flags are cached on the cell once per layout so styles read them O(1). Styles: PSMTahoeTabStyle draws one rounded track per row with the selected pill inset; PSMYosemiteTabStyle draws per-row separators; PSMMinimalTabStyle draws a per-row notch outline for both top and bottom tab positions. Window chrome: a two-row bar exceeds the titlebar-accessory height, so it moves into the content view; the window number and title labels stay centered on the first row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second round of review fixes. All of these are about paths that still assumed a single left-to-right row after the geometry became two-row aware. Tahoe: the inter-cell dividers were computed by sorting cells on frame.minX alone, which interleaves the two rows, so the rightmost cell of one row was treated as the X-neighbor of a cell on the other. That drew a spurious divider just past the last cell of the upper row, and hovering the lower row's rightmost cell suppressed a divider up on the upper row. Sort by (minY, minX) and only draw between cells that share a row. Compared geometrically rather than via the cached isFirst/isLastInHorizontalRow flags because the animated layout path doesn't refresh those. Tahoe: the two-row background/clip tracks used the hardcoded single-row barHeight (28) while the stride came from the shared geometry, so the tracks gapped or overlapped, and stopped aligning with the cell rows, wherever the per-row height isn't 28pt. Take the height from twoRowContentHeight too. Minimal: the line between the two rows was stroked to NSMaxX(bar.frame), running straight through the overflow chevron (which spans the whole bar height). The inter-row line now stops at the right margin when the chevron is visible; the bar's outer edge against the content still spans full width, since it passes below the chevron rather than across it. The selected-tab notch on the inter-row boundary gets the same treatment via a rightEdge: parameter. Hit testing: cellForPoint: early-returned on the generic cell rect, which neither row fits inside — the lower row starts at the reclaimed left edge and the pair of rows extends to the smaller two-row bottom inset. In compact that left roughly 75pt of the lower row's first tab (under the traffic lights) dead to clicks, drags, close buttons, and tooltips. Added cellHitTestBoundsWithOverflow: for the region the cells really occupy. Drag and drop: "left of the first tab" was measured against the style's left margin, so dropping anywhere in the lower row's reclaimed inset inserted at the very front of the upper row. It now measures against the left edge of the row under the mouse (leftEdgeOfHorizontalCellRowAtY:). The drag reflow also advanced its x cursor across every cell in sequence, which pushed the lower row's tabs off the right edge; it now restarts at each row's own left edge. Both gated so the single-row path is untouched. Add-tab button: moved from the top row to the bottom row. Both rows stretch to the same reserved right margin, so on the top row the + sat in space the top row's own tabs already reach while the bottom row's reserved slot went unused. Row-boundary flags: only finishUpdateWithRegularWidths: refreshed them, and the animated width path returns before reaching it, so the add/remove-tab animation drew with flags from the previous cell set and the end separators could land on the wrong tab. This was a regression to the default, feature-off single-row experience. Refresh them when the animation starts. Row-count decision: horizontalRowCount estimated capacity as cellMinWidth per cell, which ignores pinned tabs' fixed width and could disagree with what one row would actually show. It now asks the same minimum-width fit question the single-row layout asks itself. Resize cost: a row-count change only needs the full refreshTerminal: window- chrome rebuild when it also flips whether the bar is a titlebar accessory. When the bar isn't moving — always the case for Minimal and Compact, which are never accessories — a reposition plus tab refit is enough, so drag-resizing across the row-count boundary no longer rebuilds the chrome. Build fix: declare -update: in PSMTabBarControl.h. The first round added [self update:NO] calls to iTermTabBarControlView's updateHeightWithDefault:, but -update: was only declared in PSMTabBarControlProtocol, and PSMTabBarControl neither lists that protocol in its interface nor imports the header declaring it, so the selector was invisible to the subclass. It only compiled against stale DerivedData; a clean build on current master fails with -Wobjc-method-access. Compact: two-row height now applies to every theme, not just Tahoe. On macOS 26 iTermTabBarControlView's setOrientation: sets the bar height from the style's tabBarHeight, and setTabLocation: calls it on every layout pass -- so it ran after updateHeightWithDefault: had set the correct two-row height and shrank the bar straight back to one row's worth. Tahoe happened to be immune because its tabBarHeight returned the doubled value itself, and Minimal because its tabBarHeight returns 0 and so fell through to the already-row-aware delegate. PSMYosemiteTabStyle returns a plain defaultTabBarHeight, which is what Compact uses, so Compact crammed both rows into a 24pt bar: each row ended up ~12pt, shorter than the traffic-light cluster, which is anchored to the top and therefore spilled into the lower row -- where the reclaimed left inset put a tab directly underneath it. That is the overlap reported in review. The two-row growth now happens once, where the style's per-row height is consumed (-heightForRowsGivenSingleRowHeight:), and PSMTahoeTabStyle.tabBarHeight reports only its single-row height like every other style. Verified on screen in Compact (overlap gone), and Minimal and Tahoe are unchanged in both one- and two-row states. Clicking a tab on the lower row: -wantsMouseDownAtPoint: rejected any point left of insets.left, and iTermTabBarControlView's -mouseDown: uses that to decide between selecting a tab and dragging the window. The lower row starts at 0, so in Compact (insets.left is ~171pt there for the traffic lights and window shortcut) its whole leading tab fell inside that strip and every click on it became performWindowDragWithEvent: -- the tab could never be selected, while hover still worked, because tracking rects are geometric, so its close button appeared on a tab that would not respond to clicks. Both edges are now row-aware: the left via -insetAdjustedLeftEdgeOfHorizontalRowAtY: (kept in insets.left terms so single-row behavior is bit-identical) and the right via -lastVisibleCellInHorizontalRowAtY:, since the two rows need not end at the same x. Keyboard selection was never affected, which is what isolated this to the mouse-down path. Fixing -cellForPoint: alone was necessary but not sufficient. Setting description and release note reworded to say what the feature actually does: a second row appears only once tabs would overflow one row, so a wide window may need many tabs before anything changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
060be90 to
cf53b38
Compare
|
ok, all four bugs fixed. Also, 5 out of 6 bullet points were addressed. |
Yes, definitely a good recommendation, but you know when it comes to user preferences... |
|
you are right |
|
Now that tabs scroll on overflow, is this still useful? |
|
sorry @gnachman I'm not sure I got you here. Are you talking about the horizontal tab scroll? |



With the advent of agentic coding tools, people tend to have more open tabs at the same time.
This PR introduces a new advanced setting to enable a dual-row tab bar.
It's a no-op if users choose to have the tabs in a sidebar.