Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ public protocol SettingsHostActions: AnyObject {
@discardableResult
func setSurfaceTabBarFontSize(_ points: Double) async -> Bool

/// Whether surface tabs stretch to fill their pane's available tab-bar
/// width. Backed by the Ghostty config file (`surface-tabs-fill-pane-width`).
func surfaceTabsFillPaneWidth() -> Bool

/// Persists the surface-tabs-fill-pane-width flag to the Ghostty config and
/// live-reloads open windows.
///
/// - Returns: `true` if the value was written and reloaded, `false` if
/// persistence failed. The disk write happens off the main actor, so this
/// is `async`; call it from a `Task` in the toggle action.
@discardableResult
func setSurfaceTabsFillPaneWidth(_ enabled: Bool) async -> Bool

/// Formats a point size for display next to a font-size slider
/// (e.g. `12`, `13.5`), trimming trailing zeros.
func formattedFontSize(_ points: Double) -> String
Expand All @@ -115,6 +128,10 @@ public extension SettingsHostActions {

func setSurfaceTabBarFontSize(_ points: Double) async -> Bool { true }

func surfaceTabsFillPaneWidth() -> Bool { false }

func setSurfaceTabsFillPaneWidth(_ enabled: Bool) async -> Bool { true }

func formattedFontSize(_ points: Double) -> String {
let scaled = (points * 100).rounded()
let whole = Int(scaled / 100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public struct TerminalSection: View {
@State private var surfaceTabBarFont: SettingsFontSize
@State private var fontSaveFailed = false
@State private var fontSaveTask: Task<Void, Never>?
@State private var tabsFillPaneWidth: Bool
@State private var tabsFillSaveTask: Task<Void, Never>?
@State private var scrollBar: DefaultsValueModel<Bool>
@State private var copyOnSelect: DefaultsValueModel<Bool>
@State private var autoResume: DefaultsValueModel<Bool>
Expand All @@ -31,6 +33,7 @@ public struct TerminalSection: View {
self.catalog = catalog
self.hostActions = hostActions
_surfaceTabBarFont = State(initialValue: hostActions.surfaceTabBarFontSize())
_tabsFillPaneWidth = State(initialValue: hostActions.surfaceTabsFillPaneWidth())
_scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar))
_copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect))
_autoResume = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.autoResumeAgentSessions))
Expand Down Expand Up @@ -58,6 +61,15 @@ public struct TerminalSection: View {
}
}

/// Persists the stretch-tabs-to-fill flag, cancelling any in-flight save so a
/// rapid toggle sequence only reflects the latest value.
private func saveTabsFillPaneWidth(_ enabled: Bool) {
tabsFillSaveTask?.cancel()
tabsFillSaveTask = Task {
_ = await hostActions.setSurfaceTabsFillPaneWidth(enabled)
Comment thread
austinywang marked this conversation as resolved.
Outdated
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@ViewBuilder
private var resumeCommandsCard: some View {
SettingsCard {
Expand Down Expand Up @@ -129,6 +141,22 @@ public struct TerminalSection: View {
}
}
SettingsCardDivider()
SettingsCardRow(
configurationReview: .settingsOnly,
String(localized: "settings.terminal.tabsFillPaneWidth", defaultValue: "Stretch Tabs to Fill Pane Width"),
subtitle: tabsFillPaneWidth
? String(localized: "settings.terminal.tabsFillPaneWidth.subtitleOn", defaultValue: "Tabs stretch to fill each pane's tab bar. A single tab spans the full width; multiple tabs share it evenly and scroll only when they overflow.")
: String(localized: "settings.terminal.tabsFillPaneWidth.subtitleOff", defaultValue: "Tabs use a fixed width and scroll horizontally when they overflow the pane.")
) {
Toggle("", isOn: Binding(get: { tabsFillPaneWidth }, set: { newValue in
tabsFillPaneWidth = newValue
saveTabsFillPaneWidth(newValue)
}))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsTerminalTabsFillPaneWidthToggle")
}
SettingsCardDivider()
SettingsCardRow(
configurationReview: .json("terminal.showScrollBar"),
String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"),
Expand Down
51 changes: 51 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -143076,6 +143076,57 @@
}
}
},
"settings.terminal.tabsFillPaneWidth": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Stretch Tabs to Fill Pane Width"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タブをペーン幅いっぱいに広げる"
}
}
}
},
"settings.terminal.tabsFillPaneWidth.subtitleOff": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tabs use a fixed width and scroll horizontally when they overflow the pane."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タブは固定幅を使用し、ペーンからあふれると横方向にスクロールします。"
}
}
}
},
"settings.terminal.tabsFillPaneWidth.subtitleOn": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tabs stretch to fill each pane's tab bar. A single tab spans the full width; multiple tabs share it evenly and scroll only when they overflow."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タブが各ペーンのタブバーいっぱいに広がります。タブが1つのときは全幅に広がり、複数あるときは均等に分け合い、あふれたときだけスクロールします。"
}
}
}
},
"settings.terminal.scrollBar": {
"extractionState": "manual",
"localizations": {
Comment thread
austinywang marked this conversation as resolved.
Expand Down
32 changes: 32 additions & 0 deletions Sources/CmuxApplicationSupportDirectories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ enum CmuxGhosttyConfigSettingEditor {
static let minSurfaceTabBarFontSize = 8.0
static let maxSurfaceTabBarFontSize = 14.0

static let surfaceTabsFillPaneWidthKey = "surface-tabs-fill-pane-width"
static let defaultSurfaceTabsFillPaneWidth = false

static func clampedSidebarFontSize(_ value: Double) -> Double {
guard value.isFinite else { return defaultSidebarFontSize }
return min(max(value, minSidebarFontSize), maxSidebarFontSize)
Expand All @@ -213,6 +216,35 @@ enum CmuxGhosttyConfigSettingEditor {
parsedFontSize(in: contents, key: surfaceTabBarFontSizeKey, clamp: clampedSurfaceTabBarFontSize)
}

/// Serializes a boolean Ghostty config value (`true`/`false`).
static func formattedBool(_ value: Bool) -> String {
value ? "true" : "false"
}

/// Reads the last occurrence of the surface-tabs-fill-pane-width flag, or `nil` if unset/unparseable.
static func parsedSurfaceTabsFillPaneWidth(in contents: String) -> Bool? {
guard let raw = parsedValue(for: surfaceTabsFillPaneWidthKey, in: contents) else {
return nil
}
return parsedBoolLiteral(raw)
}

/// Parses a Ghostty-style boolean literal, accepting the common truthy/falsy spellings.
static func parsedBoolLiteral(_ rawValue: String) -> Bool? {
let normalized = rawValue
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
.trimmingCharacters(in: .whitespaces)
.lowercased()
switch normalized {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return nil
}
}

/// Formats a point size for display, trimming trailing zeros (`12`, `13.5`, `13.75`).
static func formattedFontSize(_ value: Double) -> String {
let scaled = Int((value * 100).rounded())
Expand Down
5 changes: 5 additions & 0 deletions Sources/GhosttyConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct GhosttyConfig {
var fontFamily: String = "Menlo"
var fontSize: CGFloat = 12
var surfaceTabBarFontSize: CGFloat = Self.defaultSurfaceTabBarFontSize
var surfaceTabsFillPaneWidth: Bool = CmuxGhosttyConfigSettingEditor.defaultSurfaceTabsFillPaneWidth
var sidebarFontSize: CGFloat = Self.defaultSidebarFontSize
var theme: String?
var workingDirectory: String?
Expand Down Expand Up @@ -408,6 +409,10 @@ struct GhosttyConfig {
if let size = Double(value), size.isFinite {
surfaceTabBarFontSize = Self.clampedSurfaceTabBarFontSize(CGFloat(size))
}
case "surface-tabs-fill-pane-width":
if let enabled = CmuxGhosttyConfigSettingEditor.parsedBoolLiteral(value) {
surfaceTabsFillPaneWidth = enabled
}
case "sidebar-font-size":
if let size = Double(value), size.isFinite {
sidebarFontSize = Self.clampedSidebarFontSize(CGFloat(size))
Expand Down
18 changes: 18 additions & 0 deletions Sources/HostSettingsActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,24 @@ final class HostSettingsActions: SettingsHostActions {
)
}

func surfaceTabsFillPaneWidth() -> Bool {
// See ``sidebarFontSize()`` — uses the cached config to avoid main-actor disk I/O.
GhosttyConfig.load().surfaceTabsFillPaneWidth
}

func setSurfaceTabsFillPaneWidth(_ enabled: Bool) async -> Bool {
let formatted = CmuxGhosttyConfigSettingEditor.formattedBool(enabled)
guard await fontConfigWriter.write(
key: CmuxGhosttyConfigSettingEditor.surfaceTabsFillPaneWidthKey,
value: formatted
) else {
hostSettingsLogger.warning("failed to persist surface-tabs-fill-pane-width")
return false
}
GhosttyApp.shared.reloadConfiguration(source: "settings.terminal.tabsFillPaneWidth")
return true
}
Comment thread
austinywang marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

func formattedFontSize(_ points: Double) -> String {
CmuxGhosttyConfigSettingEditor.formattedFontSize(points)
}
Expand Down
27 changes: 22 additions & 5 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10680,10 +10680,18 @@ final class Workspace: Identifiable, ObservableObject {
bonsplitAppearance(
from: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity,
tabTitleFontSize: config.surfaceTabBarFontSize
tabTitleFontSize: config.surfaceTabBarFontSize,
tabWidthMode: Self.bonsplitTabWidthMode(for: config)
)
}

/// Maps the `surface-tabs-fill-pane-width` config flag to Bonsplit's tab-width mode.
nonisolated static func bonsplitTabWidthMode(
for config: GhosttyConfig
) -> BonsplitConfiguration.Appearance.TabWidthMode {
config.surfaceTabsFillPaneWidth ? .fill : .fixed
}

nonisolated static func usesSharedSurfaceBackdrop(defaults: UserDefaults = .standard) -> Bool {
defaults.bool(forKey: "sidebarMatchTerminalBackground")
}
Expand Down Expand Up @@ -10816,7 +10824,8 @@ final class Workspace: Identifiable, ObservableObject {
private static func bonsplitAppearance(
from backgroundColor: NSColor,
backgroundOpacity: Double,
tabTitleFontSize: CGFloat = 11
tabTitleFontSize: CGFloat = 11,
tabWidthMode: BonsplitConfiguration.Appearance.TabWidthMode = .fixed
) -> BonsplitConfiguration.Appearance {
let sharesWindowBackdrop = usesWindowRootTerminalBackdrop()
let renderingMode = WindowAppearanceSnapshot.terminalRenderingMode(
Expand All @@ -10831,6 +10840,7 @@ final class Workspace: Identifiable, ObservableObject {
return BonsplitConfiguration.Appearance(
tabBarHeight: WindowChromeMetrics.bonsplitTabBarHeight,
tabTitleFontSize: tabTitleFontSize,
tabWidthMode: tabWidthMode,
splitButtonBackdropEffect: Self.bonsplitSplitButtonBackdropEffect(),
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
Expand All @@ -10851,15 +10861,18 @@ final class Workspace: Identifiable, ObservableObject {
renderingMode: renderingMode
)
let nextTabTitleFontSize = config.surfaceTabBarFontSize
let nextTabWidthMode = Self.bonsplitTabWidthMode(for: config)
let currentAppearance = bonsplitController.configuration.appearance
let currentTabTitleFontSize = currentAppearance.tabTitleFontSize
let currentTabWidthMode = currentAppearance.tabWidthMode
let colorsChanged = !Self.bonsplitChromeColorsEqual(
currentAppearance.chromeColors,
nextChromeColors
)
let sharedBackdropChanged = currentAppearance.usesSharedBackdrop != sharesWindowBackdrop
let fontSizeChanged = abs(currentTabTitleFontSize - nextTabTitleFontSize) > 0.0001
let isNoOp = !colorsChanged && !sharedBackdropChanged && !fontSizeChanged
let tabWidthModeChanged = currentTabWidthMode != nextTabWidthMode
let isNoOp = !colorsChanged && !sharedBackdropChanged && !fontSizeChanged && !tabWidthModeChanged

if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
Expand All @@ -10886,6 +10899,9 @@ final class Workspace: Identifiable, ObservableObject {
if fontSizeChanged {
bonsplitController.configuration.appearance.tabTitleFontSize = nextTabTitleFontSize
}
if tabWidthModeChanged {
bonsplitController.configuration.appearance.tabWidthMode = nextTabWidthMode
}

if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
Expand Down Expand Up @@ -10974,11 +10990,12 @@ final class Workspace: Identifiable, ObservableObject {
// and keep split entry instantaneous.
// Use the cached Ghostty config so new workspaces inherit tab-strip sizing
// without paying repeated parse costs on the workspace-creation hot path.
let initialSurfaceTabBarFontSize = GhosttyConfig.load().surfaceTabBarFontSize
let initialConfig = GhosttyConfig.load()
let appearance = Self.bonsplitAppearance(
from: GhosttyApp.shared.defaultBackgroundColor,
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity,
tabTitleFontSize: initialSurfaceTabBarFontSize
tabTitleFontSize: initialConfig.surfaceTabBarFontSize,
tabWidthMode: Self.bonsplitTabWidthMode(for: initialConfig)
)
let config = BonsplitConfiguration(
allowSplits: true,
Expand Down
Loading
Loading