diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 82cf4c55b35..dff27fba534 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -100,6 +100,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 @@ -172,6 +185,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) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index c9615a9ea8f..7dcb4fcdfc8 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -14,6 +14,11 @@ public struct TerminalSection: View { @State private var surfaceTabBarFont: SettingsFontSize @State private var fontSaveFailed = false @State private var fontSaveTask: Task? + @State private var tabsFillPaneWidth: Bool + @State private var tabsFillPaneWidthLastSaved: Bool + @State private var tabsFillSaveFailed = false + @State private var tabsFillSaveTask: Task? + @State private var tabsFillSaveGeneration = 0 @State private var scrollBar: DefaultsValueModel @State private var copyOnSelect: DefaultsValueModel @State private var autoResume: DefaultsValueModel @@ -31,6 +36,9 @@ public struct TerminalSection: View { self.catalog = catalog self.hostActions = hostActions _surfaceTabBarFont = State(initialValue: hostActions.surfaceTabBarFontSize()) + let initialTabsFillPaneWidth = hostActions.surfaceTabsFillPaneWidth() + _tabsFillPaneWidth = State(initialValue: initialTabsFillPaneWidth) + _tabsFillPaneWidthLastSaved = State(initialValue: initialTabsFillPaneWidth) _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)) @@ -58,6 +66,32 @@ public struct TerminalSection: View { } } + /// Persists the stretch-tabs-to-fill flag in request order while cancelling + /// superseded saves that have not started writing yet. + private func saveTabsFillPaneWidth(_ enabled: Bool) { + let previousSaveTask = tabsFillSaveTask + previousSaveTask?.cancel() + tabsFillSaveGeneration += 1 + let saveGeneration = tabsFillSaveGeneration + tabsFillSaveFailed = false + tabsFillSaveTask = Task { + if let previousSaveTask { + await previousSaveTask.value + } + guard !Task.isCancelled else { return } + let saved = await hostActions.setSurfaceTabsFillPaneWidth(enabled) + if saved { + tabsFillPaneWidthLastSaved = enabled + if saveGeneration == tabsFillSaveGeneration { + tabsFillSaveFailed = false + } + } else if saveGeneration == tabsFillSaveGeneration && !Task.isCancelled { + tabsFillPaneWidth = tabsFillPaneWidthLastSaved + tabsFillSaveFailed = true + } + } + } + @ViewBuilder private var resumeCommandsCard: some View { SettingsCard { @@ -129,6 +163,33 @@ 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."), + controlWidth: 250 + ) { + VStack(alignment: .trailing, spacing: 4) { + Toggle("", isOn: Binding(get: { tabsFillPaneWidth }, set: { newValue in + tabsFillPaneWidth = newValue + saveTabsFillPaneWidth(newValue) + })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalTabsFillPaneWidthToggle") + + if tabsFillSaveFailed { + Text(String(localized: "settings.terminal.tabsFillPaneWidth.saveFailed", defaultValue: "Couldn't save tab stretch setting. Please try again.")) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } + } + } + SettingsCardDivider() SettingsCardRow( configurationReview: .json("terminal.showScrollBar"), String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d8004c2ccf5..ed53d544ca0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -147904,6 +147904,506 @@ } } }, + "settings.terminal.tabsFillPaneWidth": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stretch Tabs to Fill Pane Width" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブをペーン幅いっぱいに広げる" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمديد الألسنة لملء عرض اللوحة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Rastegni kartice da popune širinu okna" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Stræk faner til at udfylde rudens bredde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tabs auf volle Bereichsbreite strecken" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estirar pestañas para llenar el ancho del panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Étirer les onglets pour remplir la largeur du volet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Estendi le schede per riempire la larghezza del riquadro" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពង្រីកផ្ទាំងឱ្យពេញទទឹងផ្ទាំង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭을 창 너비에 맞게 늘리기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Strekk faner for å fylle rutebredden" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozciągnij karty do szerokości panelu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esticar abas para preencher a largura do painel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Растягивать вкладки на всю ширину панели" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยืดแท็บให้เต็มความกว้างของบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeleri bölme genişliğini dolduracak şekilde uzat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розтягувати вкладки на всю ширину панелі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拉伸标签页以填满窗格宽度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拉伸分頁以填滿窗格寬度" + } + } + } + }, + "settings.terminal.tabsFillPaneWidth.saveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't save tab stretch setting. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブの伸縮設定を保存できませんでした。もう一度お試しください。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر حفظ إعداد تمديد الألسنة. يرجى المحاولة مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće sačuvati postavku rastezanja kartica. Pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke gemme indstillingen for fanestrækning. Prøv igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Einstellung zum Strecken der Tabs konnte nicht gespeichert werden. Bitte versuche es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo guardar el ajuste para estirar las pestañas. Inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’enregistrer le réglage d’étirement des onglets. Réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile salvare l’impostazione di estensione delle schede. Riprova." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចរក្សាទុកការកំណត់ពង្រីកផ្ទាំងបានទេ។ សូមព្យាយាមម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭 늘리기 설정을 저장할 수 없습니다. 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke lagre innstillingen for fanestrekking. Prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się zapisać ustawienia rozciągania kart. Spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível salvar o ajuste de esticar abas. Tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось сохранить настройку растягивания вкладок. Повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บันทึกการตั้งค่าการยืดแท็บไม่ได้ โปรดลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekme uzatma ayarı kaydedilemedi. Lütfen tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося зберегти налаштування розтягування вкладок. Спробуйте ще раз." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法保存标签页拉伸设置。请重试。" + } + }, + "zh-Hant": { + "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": "タブは固定幅を使用し、ペーンからあふれると横方向にスクロールします。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تستخدم الألسنة عرضًا ثابتًا وتمرّر أفقيًا عند تجاوزها اللوحة." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kartice koriste fiksnu širinu i pomiču se vodoravno kada prelaze okno." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Faner bruger en fast bredde og ruller vandret, når de flyder over ruden." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tabs verwenden eine feste Breite und scrollen horizontal, wenn sie über den Bereich hinausragen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las pestañas usan un ancho fijo y se desplazan horizontalmente cuando desbordan el panel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les onglets utilisent une largeur fixe et défilent horizontalement lorsqu’ils dépassent le volet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le schede usano una larghezza fissa e scorrono orizzontalmente quando superano il riquadro." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទាំងប្រើទទឹងថេរ ហើយរមូរផ្តេកនៅពេលវាលើសផ្ទាំង។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭은 고정 너비를 사용하며 창을 넘치면 가로로 스크롤됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Faner bruker fast bredde og ruller vannrett når de går utenfor ruten." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karty używają stałej szerokości i przewijają się poziomo, gdy wykraczają poza panel." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As abas usam largura fixa e rolam horizontalmente quando excedem o painel." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладки используют фиксированную ширину и прокручиваются по горизонтали, когда не помещаются в панели." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บใช้ความกว้างคงที่และเลื่อนในแนวนอนเมื่อแท็บล้นบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeler sabit genişlik kullanır ve bölmeden taştığında yatay kaydırılır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вкладки мають фіксовану ширину й прокручуються горизонтально, коли не вміщаються в панель." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页使用固定宽度,超出窗格时会水平滚动。" + } + }, + "zh-Hant": { + "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つのときは全幅に広がり、複数あるときは均等に分け合い、あふれたときだけスクロールします。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتمدد الألسنة لملء شريط ألسنة كل لوحة. يشغل اللسان الواحد العرض بالكامل؛ وتتقاسم الألسنة المتعددة العرض بالتساوي ولا تمرّر إلا عند التجاوز." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kartice se rastežu da popune traku kartica svakog okna. Jedna kartica zauzima punu širinu; više kartica je ravnomjerno dijeli i pomiče se samo kada prelazi prostor." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Faner strækkes til at udfylde hver rudes fanelinje. Én fane fylder hele bredden; flere faner deler den ligeligt og ruller kun, når de flyder over." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tabs werden gestreckt, um die Tableiste jedes Bereichs zu füllen. Ein einzelner Tab nutzt die volle Breite; mehrere Tabs teilen sie gleichmäßig und scrollen erst, wenn sie überlaufen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las pestañas se estiran para llenar la barra de pestañas de cada panel. Una sola pestaña ocupa todo el ancho; varias lo comparten por igual y solo se desplazan cuando desbordan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les onglets s’étirent pour remplir la barre d’onglets de chaque volet. Un seul onglet occupe toute la largeur; plusieurs onglets la partagent équitablement et ne défilent qu’en cas de dépassement." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le schede si estendono per riempire la barra delle schede di ogni riquadro. Una sola scheda occupa tutta la larghezza; più schede la dividono in modo uniforme e scorrono solo quando superano lo spazio." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទាំងពង្រីកដើម្បីបំពេញរបារផ្ទាំងនៃផ្ទាំងនីមួយៗ។ ផ្ទាំងតែមួយគ្របទទឹងទាំងមូល; ផ្ទាំងច្រើនចែករំលែកទទឹងស្មើៗគ្នា ហើយរមូរតែនៅពេលលើសប៉ុណ្ណោះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭은 각 창의 탭 막대를 채우도록 늘어납니다. 탭이 하나이면 전체 너비를 차지하고, 여러 탭은 너비를 균등하게 나누며 넘칠 때만 스크롤됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Faner strekkes for å fylle fanelinjen i hver rute. Én fane dekker hele bredden; flere faner deler den jevnt og ruller bare når de går utenfor." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karty rozciągają się, aby wypełnić pasek kart każdego panelu. Jedna karta zajmuje całą szerokość; wiele kart dzieli ją równo i przewija się tylko po przepełnieniu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As abas se esticam para preencher a barra de abas de cada painel. Uma única aba ocupa toda a largura; várias abas a dividem igualmente e só rolam quando excedem o espaço." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладки растягиваются, заполняя панель вкладок каждого окна. Одна вкладка занимает всю ширину; несколько вкладок делят её поровну и прокручиваются только при переполнении." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บจะยืดให้เต็มแถบแท็บของแต่ละบานหน้าต่าง แท็บเดียวกินความกว้างทั้งหมด หลายแท็บแบ่งพื้นที่เท่า ๆ กัน และเลื่อนเฉพาะเมื่อพื้นที่ล้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sekmeler her bölmenin sekme çubuğunu dolduracak şekilde uzar. Tek sekme tüm genişliği kaplar; birden çok sekme genişliği eşit paylaşır ve yalnızca taştığında kaydırılır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вкладки розтягуються, щоб заповнити панель вкладок кожної панелі. Одна вкладка займає всю ширину; кілька вкладок ділять її порівну й прокручуються лише при переповненні." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页会拉伸以填满每个窗格的标签栏。单个标签页占满全宽;多个标签页平均分配宽度,只有溢出时才滚动。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分頁會拉伸以填滿每個窗格的分頁列。單一分頁佔滿全寬;多個分頁平均分配寬度,只有溢出時才捲動。" + } + } + } + }, "settings.terminal.scrollBar": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxApplicationSupportDirectories.swift b/Sources/CmuxApplicationSupportDirectories.swift index 59ede1e88a1..d5c0ece6ad0 100644 --- a/Sources/CmuxApplicationSupportDirectories.swift +++ b/Sources/CmuxApplicationSupportDirectories.swift @@ -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) @@ -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()) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index ec8ca23c0ca..cb0f605204d 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -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? @@ -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)) diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 9e96f9c60dd..e819e87c346 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -16,8 +16,8 @@ private let hostSettingsLogger = Logger(subsystem: "com.cmuxterm.app", category: final class HostSettingsActions: SettingsHostActions { private let configFileURL: URL - /// Serializes font-size config writes so rapid slider saves persist in order. - private let fontConfigWriter = FontConfigWriter() + /// Serializes editable Ghostty config writes so rapid settings saves persist in order. + private let ghosttyConfigWriter = GhosttyConfigWriter() /// AppKit window identifier the dedicated terminal-config window carries. /// Matches the value `ConfigSettingsView.configureWindow` assigns so the @@ -199,6 +199,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 ghosttyConfigWriter.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 + } + func formattedFontSize(_ points: Double) -> String { CmuxGhosttyConfigSettingEditor.formattedFontSize(points) } @@ -300,7 +318,7 @@ final class HostSettingsActions: SettingsHostActions { /// Writes a clamped font-size value to cmux's editable Ghostty config and /// triggers a live reload so open windows re-render at the new size. /// - /// The disk write runs on the serial ``fontConfigWriter`` actor so the main + /// The disk write runs on the serial ``ghosttyConfigWriter`` actor so the main /// actor is never blocked on file I/O during a slider drag or Reset tap, and /// rapid successive saves persist in submission order (last value wins). The /// reload then resumes on the main actor. @@ -309,7 +327,7 @@ final class HostSettingsActions: SettingsHostActions { /// warning is logged here; the Settings UI surfaces a save-failed message). private func persistFontSize(key: String, points: Double, reloadSource: String) async -> Bool { let formatted = CmuxGhosttyConfigSettingEditor.formattedFontSize(points) - guard await fontConfigWriter.write(key: key, value: formatted) else { + guard await ghosttyConfigWriter.write(key: key, value: formatted) else { hostSettingsLogger.warning("failed to persist \(key, privacy: .public)") return false } @@ -335,18 +353,19 @@ private final class MobileHostStatusObserverToken: @unchecked Sendable { } } -/// Serializes cmux Ghostty config writes for the font-size settings so rapid +/// Serializes cmux Ghostty config writes so rapid /// successive saves apply in submission order instead of racing. /// -/// The Settings sliders fire a save on every release and Reset tap. Routed -/// through this single actor, the writes run one-at-a-time in arrival order — -/// each write is a full overwrite of the key, so the most recently submitted -/// value is always the one left on disk. The work runs off the main actor. -private actor FontConfigWriter { +/// Settings controls can fire several saves in quick succession. Routed through +/// this single actor, the writes run one-at-a-time in arrival order — each write +/// is a full overwrite of the key, so the most recently submitted value is +/// always the one left on disk. The work runs off the main actor. +private actor GhosttyConfigWriter { /// Writes a single cmux-editable Ghostty config setting to disk. /// /// - Parameters: - /// - key: The Ghostty config key to write (e.g. `sidebar-font-size`). + /// - key: The Ghostty config key to write (e.g. `sidebar-font-size` or + /// `surface-tabs-fill-pane-width`). /// - value: The already-formatted value to persist. /// - Returns: `true` if the write succeeded, `false` otherwise. func write(key: String, value: String) -> Bool { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c6a1d365e6a..a32728e4a17 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10747,10 +10747,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") } @@ -10883,7 +10891,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( @@ -10898,6 +10907,7 @@ final class Workspace: Identifiable, ObservableObject { return BonsplitConfiguration.Appearance( tabBarHeight: WindowChromeMetrics.bonsplitTabBarHeight, tabTitleFontSize: tabTitleFontSize, + tabWidthMode: tabWidthMode, splitButtonBackdropEffect: Self.bonsplitSplitButtonBackdropEffect(), splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, @@ -10918,15 +10928,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( @@ -10953,6 +10966,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( @@ -11041,11 +11057,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, diff --git a/Sources/WorkspaceAppearanceResolution.swift b/Sources/WorkspaceAppearanceResolution.swift index bfabb7b1e72..d3399a7212c 100644 --- a/Sources/WorkspaceAppearanceResolution.swift +++ b/Sources/WorkspaceAppearanceResolution.swift @@ -47,6 +47,7 @@ extension WorkspaceContentView { String(format: "%.4f", config.backgroundOpacity), String(describing: config.backgroundBlur), String(format: "%.4f", config.surfaceTabBarFontSize), + String(config.surfaceTabsFillPaneWidth), String(format: "%.4f", config.unfocusedSplitOpacity), config.unfocusedSplitFill?.hexString(includeAlpha: true) ?? "nil", config.splitDividerColor?.hexString(includeAlpha: true) ?? "nil", diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 16ed2331523..1539132a82b 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -4945,6 +4945,113 @@ struct SurfaceTabBarFontSizeConfigTests { } } +@Suite +struct SurfaceTabsFillPaneWidthConfigTests { + @Test func defaultsToDisabled() { + let config = GhosttyConfig() + + #expect(config.surfaceTabsFillPaneWidth == false) + #expect(config.surfaceTabsFillPaneWidth == CmuxGhosttyConfigSettingEditor.defaultSurfaceTabsFillPaneWidth) + } + + @Test func parsesTrueValue() { + var config = GhosttyConfig() + + config.parse("surface-tabs-fill-pane-width = true") + + #expect(config.surfaceTabsFillPaneWidth == true) + } + + @Test func parsesFalseValue() { + var config = GhosttyConfig() + + config.parse("surface-tabs-fill-pane-width = true") + config.parse("surface-tabs-fill-pane-width = false") + + #expect(config.surfaceTabsFillPaneWidth == false) + } + + @Test(arguments: ["1", "yes", "on", "TRUE", "On"]) + func parsesAlternateTruthyForms(_ raw: String) { + var config = GhosttyConfig() + + config.parse("surface-tabs-fill-pane-width = \(raw)") + + #expect(config.surfaceTabsFillPaneWidth == true) + } + + @Test(arguments: ["0", "no", "off", "FALSE", "Off"]) + func parsesAlternateFalsyForms(_ raw: String) { + var config = GhosttyConfig() + + config.parse("surface-tabs-fill-pane-width = true") + config.parse("surface-tabs-fill-pane-width = \(raw)") + + #expect(config.surfaceTabsFillPaneWidth == false) + } + + @Test func ignoresUnparseableValue() { + var config = GhosttyConfig() + + config.parse("surface-tabs-fill-pane-width = true") + config.parse("surface-tabs-fill-pane-width = maybe") + + #expect(config.surfaceTabsFillPaneWidth == true) + } + + @Test func loadUsesParsedFlagFromInjectedLoader() { + let loaded = GhosttyConfig.load( + preferredColorScheme: .dark, + useCache: false, + loadFromDisk: { _ in + var config = GhosttyConfig() + config.parse("surface-tabs-fill-pane-width = true") + return config + } + ) + + #expect(loaded.surfaceTabsFillPaneWidth == true) + } + + @Test func editorParsesLastFillValue() { + let contents = """ + surface-tabs-fill-pane-width = false + surface-tabs-fill-pane-width = true + """ + + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabsFillPaneWidth(in: contents) == true) + } + + @Test func editorReturnsNilWhenFillValueAbsent() { + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabsFillPaneWidth(in: "sidebar-font-size = 14") == nil) + } + + @Test func editorFormatsBool() { + #expect(CmuxGhosttyConfigSettingEditor.formattedBool(true) == "true") + #expect(CmuxGhosttyConfigSettingEditor.formattedBool(false) == "false") + } + + @Test func editorWriteSettingRoundTripsFillValue() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tabs-fill-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("config.ghostty") + try "font-size = 13\n".write(to: url, atomically: true, encoding: .utf8) + + try CmuxGhosttyConfigSettingEditor.writeSetting( + key: CmuxGhosttyConfigSettingEditor.surfaceTabsFillPaneWidthKey, + value: CmuxGhosttyConfigSettingEditor.formattedBool(true), + to: url + ) + + let contents = try String(contentsOf: url, encoding: .utf8) + #expect(contents.contains("surface-tabs-fill-pane-width = true")) + #expect(contents.contains("font-size = 13")) + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabsFillPaneWidth(in: contents) == true) + } +} + final class ZshShellIntegrationHandoffTests: XCTestCase { func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) diff --git a/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift b/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift index 06291792f5d..eb5733c8a9e 100644 --- a/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift +++ b/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift @@ -72,4 +72,17 @@ final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822") } + + func testAppearanceSignatureIncludesSurfaceTabsFillPaneWidth() { + var fixedWidth = GhosttyConfig() + fixedWidth.surfaceTabsFillPaneWidth = false + + var fillWidth = fixedWidth + fillWidth.surfaceTabsFillPaneWidth = true + + XCTAssertNotEqual( + WorkspaceContentView.ghosttyAppearanceSignature(fixedWidth, usesHostLayerBackground: false), + WorkspaceContentView.ghosttyAppearanceSignature(fillWidth, usesHostLayerBackground: false) + ) + } } diff --git a/vendor/bonsplit b/vendor/bonsplit index ddb46fe94fb..887ece7a1b2 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ddb46fe94fbbd1efd062d80371ca2455c4296eb5 +Subproject commit 887ece7a1b2a1a5ffc93fb2f2db948212cdf0871 diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index c15cd825614..789e3e73f56 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -308,6 +308,7 @@ touch ~/.config/ghostty/config`} font-size = 13 sidebar-font-size = 14 surface-tab-bar-font-size = 11 +surface-tabs-fill-pane-width = true theme = One Dark scrollback-limit = 50000000 split-divider-color = #3e4451