Make workspace tabs shrink adaptively
This commit is contained in:
1 parent
4fb25a6a48
commit
1a698c30dc
5 files changed
+247
-23
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user