From 1a698c30dc80fb7a178ea3520cd5884dbb8fe907 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 24 Aug 2026 22:15:21 +0800 Subject: [PATCH] Make workspace tabs shrink adaptively --- Termind/Design/DesignTokens.swift | 9 +- Termind/Features/Tabs/TabBarView.swift | 75 +++++++++++++- Termind/Features/Tabs/TabItemView.swift | 37 ++++--- .../Features/Tabs/WorkspaceTabSizing.swift | 51 ++++++++++ TermindTests/WorkspaceTabSizingTests.swift | 98 +++++++++++++++++++ 5 files changed, 247 insertions(+), 23 deletions(-) create mode 100644 Termind/Features/Tabs/WorkspaceTabSizing.swift create mode 100644 TermindTests/WorkspaceTabSizingTests.swift diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index df783376..cbb0d6fd 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -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 } diff --git a/Termind/Features/Tabs/TabBarView.swift b/Termind/Features/Tabs/TabBarView.swift index ec55456b..a2426da4 100644 --- a/Termind/Features/Tabs/TabBarView.swift +++ b/Termind/Features/Tabs/TabBarView.swift @@ -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 { diff --git a/Termind/Features/Tabs/TabItemView.swift b/Termind/Features/Tabs/TabItemView.swift index 64d5f780..71e9acec 100644 --- a/Termind/Features/Tabs/TabItemView.swift +++ b/Termind/Features/Tabs/TabItemView.swift @@ -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)) diff --git a/Termind/Features/Tabs/WorkspaceTabSizing.swift b/Termind/Features/Tabs/WorkspaceTabSizing.swift new file mode 100644 index 00000000..17a2ea52 --- /dev/null +++ b/Termind/Features/Tabs/WorkspaceTabSizing.swift @@ -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..