Make workspace tabs shrink adaptively

This commit is contained in:
Kaede Akino committed 2026-08-24 22:15:21 +08:00
1 parent 4fb25a6a48
commit 1a698c30dc
5 files changed
+247 -23

No files matched your search

+7 -2
View File
@@ -2,7 +2,7 @@ import SwiftUI
/// Central layout constants. Kept as plain numbers rather than a theme object
/// because none of these need to vary at runtime — only the terminal palette does.
enum Metrics {
nonisolated enum Metrics {
/// 4pt base grid. Use these instead of literals so density stays consistent.
enum Space {
static let xxs: CGFloat = 2
@@ -41,7 +41,12 @@ enum Metrics {
enum TabBar {
static let height: CGFloat = 34
static let minTabWidth: CGFloat = 120
/// A titled tab never becomes narrower than this. The selected
/// Workspace keeps this floor while its neighbours continue down to
/// `compactTabWidth`.
static let titledTabMinWidth: CGFloat = 120
/// Icon + one spacing unit + the status/close slot + horizontal padding.
static let compactTabWidth: CGFloat = 48
static let maxTabWidth: CGFloat = 240
static let iconSize: CGFloat = 12
}
+70 -5
View File
@@ -30,6 +30,9 @@ struct TabBarView: View {
/// Each tab's frame in the strip's coordinate space, which is how a pointer
/// position becomes an insertion index. Reported by `TabItemView`.
@State private var tabFrames: [Workspace.ID: CGRect] = [:]
/// The viewport left between the pinned navigation and trailing actions.
/// Content may become wider than this once every tab reaches its floor.
@State private var availableTabStripWidth: CGFloat = 0
/// Named so the tabs and the drop delegate measure against the same origin.
///
@@ -61,6 +64,7 @@ struct TabBarView: View {
// neighbour temporarily becomes the merge target
// and should look active too.
isSelected: tab.id == window.displayedTab?.id,
width: width(for: tab),
frame: binding(for: tab)
)
.id(tab.id)
@@ -77,6 +81,11 @@ struct TabBarView: View {
.animation(Motion.tabSwitch, value: ghostIndex)
}
.coordinateSpace(.named(Self.stripSpace))
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
availableTabStripWidth = $0
}
// On the ScrollView rather than its content, so the target spans
// the whole strip — including the gap past the last tab, which
// is where a pane goes to become the last tab.
@@ -92,6 +101,12 @@ struct TabBarView: View {
guard let id else { return }
withAnimation(Motion.tabSwitch) { proxy.scrollTo(id) }
}
.onChange(of: availableTabStripWidth) {
scrollSelectedIntoView(using: proxy)
}
.onChange(of: window.tabs.count) {
scrollSelectedIntoView(using: proxy)
}
}
Divider()
@@ -141,6 +156,54 @@ struct TabBarView: View {
return window.tabs.filter { $0.id != id }
}
/// Stable per-identity widths for the real tabs. A drag source keeps its
/// allocation even while its view is collapsed to zero, and the ghost takes
/// that width at the insertion point. Existing tabs therefore never move on
/// the leading side of the pointer merely because a ghost appeared.
private var tabWidths: [CGFloat] {
return WorkspaceTabSizing.widths(
availableWidth: availableTabStripWidth,
tabCount: window.tabs.count,
selectedIndex: window.selectedIndex
)
}
private func width(for tab: Workspace) -> CGFloat {
guard let index = window.tabs.firstIndex(where: { $0.id == tab.id }),
tabWidths.indices.contains(index)
else {
return Metrics.TabBar.compactTabWidth
}
return tabWidths[index]
}
private var ghostWidth: CGFloat {
switch window.dragging {
case .workspace(let id):
guard let tab = window.tabs.first(where: { $0.id == id }) else {
return Metrics.TabBar.titledTabMinWidth
}
return width(for: tab)
case .pane:
let widths = WorkspaceTabSizing.widths(
availableWidth: availableTabStripWidth,
tabCount: window.tabs.count + 1,
selectedIndex: window.selectedIndex
)
return widths.last ?? Metrics.TabBar.titledTabMinWidth
case nil:
return Metrics.TabBar.titledTabMinWidth
}
}
private func scrollSelectedIntoView(using proxy: ScrollViewProxy) {
guard let id = window.selectedTabID else { return }
Task { @MainActor in
await Task.yield()
withAnimation(Motion.tabSwitch) { proxy.scrollTo(id) }
}
}
private var draggedWorkspaceID: Workspace.ID? {
guard case .workspace(let id)? = window.dragging else { return nil }
return id
@@ -180,14 +243,16 @@ struct TabBarView: View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: symbol)
.font(.system(size: Metrics.TabBar.iconSize))
Text(title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
if ghostWidth > Metrics.TabBar.compactTabWidth {
Text(title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
}
.foregroundStyle(Color.accentColor)
.padding(.horizontal, Metrics.Space.m)
.frame(width: Metrics.TabBar.minTabWidth)
.frame(width: ghostWidth)
.frame(maxHeight: .infinity)
.background(Color.accentColor.opacity(0.12))
.overlay {
+21 -16
View File
@@ -24,6 +24,7 @@ import SwiftUI
struct TabItemView: View {
let tab: Workspace
let isSelected: Bool
let width: CGFloat
/// Written back so `TabBarView` can turn a pointer position into an
/// insertion index without asking each tab whether the drag is over it.
@Binding var frame: CGRect
@@ -42,27 +43,26 @@ struct TabItemView: View {
.font(.system(size: Metrics.TabBar.iconSize))
.foregroundStyle(isSelected ? .primary : .secondary)
if isRenaming {
TextField("Tab Name", text: $draftTitle)
.textFieldStyle(.plain)
.font(.system(size: 11, weight: .medium))
.focused($titleFieldFocused)
.onSubmit(commitRename)
.onExitCommand { isRenaming = false }
} else {
titleStack
}
if !isCompact {
if isRenaming {
TextField("Tab Name", text: $draftTitle)
.textFieldStyle(.plain)
.font(.system(size: 11, weight: .medium))
.focused($titleFieldFocused)
.onSubmit(commitRename)
.onExitCommand { isRenaming = false }
} else {
titleStack
}
Spacer(minLength: 0)
Spacer(minLength: 0)
}
trailingControl
}
.padding(.horizontal, Metrics.Space.m)
.frame(
minWidth: Metrics.TabBar.minTabWidth,
maxWidth: Metrics.TabBar.maxTabWidth,
maxHeight: .infinity
)
.frame(width: width)
.frame(maxHeight: .infinity)
.background(background)
.overlay(alignment: .leading) {
Rectangle()
@@ -89,11 +89,16 @@ struct TabItemView: View {
frame = $0
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(tab.title(in: app.hosts)))
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
// MARK: - Pieces
private var isCompact: Bool {
width <= Metrics.TabBar.compactTabWidth
}
private var titleStack: some View {
VStack(alignment: .leading, spacing: 0) {
Text(tab.title(in: app.hosts))
@@ -0,0 +1,51 @@
import CoreGraphics
/// Pure width allocation for the scrolling Workspace portion of the tab bar.
///
/// Tabs shrink together until a title would become too narrow. After that the
/// selected Workspace keeps one titled slot while its neighbours continue down
/// to the icon-only compact width. Once every width reaches its floor, returning
/// more content than the viewport is intentional: `ScrollView` becomes the
/// overflow mechanism.
nonisolated enum WorkspaceTabSizing {
static func widths(
availableWidth: CGFloat,
tabCount: Int,
selectedIndex: Int?
) -> [CGFloat] {
guard tabCount > 0 else { return [] }
let availableWidth = availableWidth.isFinite ? max(availableWidth, 0) : 0
let equalWidth = min(
Metrics.TabBar.maxTabWidth,
max(Metrics.TabBar.compactTabWidth, availableWidth / CGFloat(tabCount))
)
guard let selectedIndex, (0..<tabCount).contains(selectedIndex) else {
return Array(repeating: equalWidth, count: tabCount)
}
if tabCount == 1 {
return [
min(
Metrics.TabBar.maxTabWidth,
max(Metrics.TabBar.titledTabMinWidth, availableWidth)
)
]
}
guard equalWidth < Metrics.TabBar.titledTabMinWidth else {
return Array(repeating: equalWidth, count: tabCount)
}
let remainingWidth = availableWidth - Metrics.TabBar.titledTabMinWidth
let neighbourWidth = min(
Metrics.TabBar.maxTabWidth,
max(
Metrics.TabBar.compactTabWidth,
remainingWidth / CGFloat(tabCount - 1)
)
)
var widths = Array(repeating: neighbourWidth, count: tabCount)
widths[selectedIndex] = Metrics.TabBar.titledTabMinWidth
return widths
}
}
@@ -0,0 +1,98 @@
import Testing
@testable import Termind
@Suite
struct WorkspaceTabSizingTests {
@Test
func noTabsProducesNoWidths() {
#expect(
WorkspaceTabSizing.widths(
availableWidth: 600,
tabCount: 0,
selectedIndex: nil
).isEmpty
)
}
@Test
func abundantSpaceCapsEveryTabAtMaximumWidth() {
let widths = WorkspaceTabSizing.widths(
availableWidth: 900,
tabCount: 3,
selectedIndex: 1
)
#expect(widths == [240, 240, 240])
}
@Test
func tabsShrinkEquallyWhileEveryTitleStillFits() {
let widths = WorkspaceTabSizing.widths(
availableWidth: 540,
tabCount: 3,
selectedIndex: 1
)
#expect(widths == [180, 180, 180])
#expect(widths.reduce(0, +) == 540)
}
@Test
func selectedTabKeepsTitledWidthWhileNeighboursContinueShrinking() {
let widths = WorkspaceTabSizing.widths(
availableWidth: 300,
tabCount: 3,
selectedIndex: 1
)
#expect(widths == [90, 120, 90])
#expect(widths.reduce(0, +) == 300)
}
@Test
func selectedTabAndCompactNeighboursOverflowAtTheirFloors() {
let widths = WorkspaceTabSizing.widths(
availableWidth: 200,
tabCount: 3,
selectedIndex: 1
)
#expect(widths == [48, 120, 48])
#expect(widths.reduce(0, +) == 216)
}
@Test
func tabsWithoutSelectedWorkspaceShareWidthDownToCompactFloor() {
let shrinking = WorkspaceTabSizing.widths(
availableWidth: 300,
tabCount: 3,
selectedIndex: nil
)
let overflowing = WorkspaceTabSizing.widths(
availableWidth: 100,
tabCount: 3,
selectedIndex: nil
)
#expect(shrinking == [100, 100, 100])
#expect(overflowing == [48, 48, 48])
}
@Test
func loneSelectedTabKeepsItsTitleFloor() {
let constrained = WorkspaceTabSizing.widths(
availableWidth: 60,
tabCount: 1,
selectedIndex: 0
)
let abundant = WorkspaceTabSizing.widths(
availableWidth: 1_000,
tabCount: 1,
selectedIndex: 0
)
#expect(constrained == [120])
#expect(abundant == [240])
}
}