Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
@@ -0,0 +1,50 @@
import Foundation

/// An `adjust-cell-height` value: extra line height added to the font's own,
/// as a percentage (`8%`, `12.5%`) or a number of device pixels (`2`).
/// Negative values tighten the lines.
public enum GhosttyCellHeightAdjustment: Equatable, Sendable {
case percent(Double)
case pixels(Int)

/// No adjustment, Ghostty's default.
public static let unadjusted = GhosttyCellHeightAdjustment.percent(0)

/// Parses Ghostty's `20%`, `-12.5%`, or `2` spelling. Like Ghostty, only
/// the ends of the value are trimmed, so `15 %` is invalid.
public init?(configValue: String) {
let value = configValue.trimmingCharacters(in: .whitespaces)
if value.hasSuffix("%") {
guard let percent = Double(value.dropLast()), percent.isFinite else { return nil }
self = .percent(percent)
} else if let pixels = Int(value) {
self = .pixels(pixels)
} else {
return nil
}
}

/// The adjustment one stepper step away: 2% for a percentage, 1 pixel
/// for a pixel value, so a value set in a config file keeps its unit.
public func stepped(by steps: Int) -> GhosttyCellHeightAdjustment {
switch self {
case .percent(let percent): return .percent((percent / 2).rounded() * 2 + Double(steps * 2))
case .pixels(let pixels):
let (sum, overflow) = pixels.addingReportingOverflow(steps)
return .pixels(overflow ? (steps > 0 ? .max : .min) : sum)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Ghostty's spelling: `8%`, `12.5%`, or `2`.
public var configValue: String {
switch self {
case .percent(let percent):
if percent == percent.rounded(), let integer = Int(exactly: percent) {
return "\(integer)%"
}
return "\(percent)%"
case .pixels(let pixels):
return String(pixels)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable {
/// font. Build it with ``GhosttyTerminalOptions/fontFamiliesChoosing(_:)``.
case fontFamilies([String])
case fontSize(Double)
case cellHeight(GhosttyCellHeightAdjustment)
case fontThicken(Bool)
case cursorStyle(GhosttyCursorStyle)
case cursorBlinks(Bool)
case windowPaddingX(GhosttyWindowPadding)
Expand All @@ -22,6 +24,8 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable {
switch self {
case .fontFamilies: return .fontFamily
case .fontSize: return .fontSize
case .cellHeight: return .adjustCellHeight
case .fontThicken: return .fontThicken
case .cursorStyle: return .cursorStyle
case .cursorBlinks: return .cursorStyleBlink
case .windowPaddingX: return .windowPaddingX
Expand All @@ -45,6 +49,10 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable {
return ["\"\""] + families.map { "\"\($0)\"" }
case .fontSize(let points):
return [numberFormatter.formattedFontSize(points)]
case .cellHeight(let adjustment):
return [adjustment.configValue]
case .fontThicken(let thicken):
return [thicken ? "true" : "false"]
case .cursorStyle(let style):
return [style.rawValue]
case .cursorBlinks(let blinks):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
public enum GhosttyTerminalOptionKey: String, CaseIterable, Sendable {
case fontFamily = "font-family"
case fontSize = "font-size"
case adjustCellHeight = "adjust-cell-height"
case fontThicken = "font-thicken"
case cursorStyle = "cursor-style"
case cursorStyleBlink = "cursor-style-blink"
case windowPaddingX = "window-padding-x"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public struct GhosttyTerminalOptions: Equatable, Sendable {
public var fontFamily: String? { fontFamilies.first }
/// The terminal font size, in points.
public var fontSize: Double
/// Extra line height on top of the font's own.
public var cellHeight: GhosttyCellHeightAdjustment
/// Whether glyphs are drawn with a thicker stroke (macOS only).
public var fontThicken: Bool
/// The default cursor shape.
public var cursorStyle: GhosttyCursorStyle
/// Whether the cursor blinks by default. Ghostty blinks when unset.
Expand Down Expand Up @@ -70,6 +74,8 @@ public struct GhosttyTerminalOptions: Equatable, Sendable {
fontSize = Self.fold(values(.fontSize)) { value in
Double(value).flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
} ?? Self.defaultFontSize
cellHeight = Self.fold(values(.adjustCellHeight), parse: GhosttyCellHeightAdjustment.init(configValue:)) ?? .unadjusted
fontThicken = Self.fold(values(.fontThicken), parse: Self.parseBool) ?? false
cursorStyle = Self.fold(values(.cursorStyle), parse: GhosttyCursorStyle.init(rawValue:)) ?? .block
cursorBlinks = Self.fold(values(.cursorStyleBlink), parse: Self.parseBool) ?? true
let defaultPadding = GhosttyWindowPadding(leading: Self.defaultWindowPadding)
Expand All @@ -93,6 +99,8 @@ public struct GhosttyTerminalOptions: Equatable, Sendable {
switch change {
case .fontFamilies(let families): updated.fontFamilies = families
case .fontSize(let points): updated.fontSize = Self.hundredths(points)
case .cellHeight(let adjustment): updated.cellHeight = adjustment
case .fontThicken(let thicken): updated.fontThicken = thicken
case .cursorStyle(let style): updated.cursorStyle = style
case .cursorBlinks(let blinks): updated.cursorBlinks = blinks
case .windowPaddingX(let points): updated.windowPaddingX = points
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct GhosttyTerminalOptionsTests {
let options = GhosttyTerminalOptions.defaults
#expect(options.fontFamily == nil)
#expect(options.fontSize == 13)
#expect(options.cellHeight == .unadjusted)
#expect(!options.fontThicken)
#expect(options.cursorStyle == .block)
#expect(options.cursorBlinks)
#expect(options.windowPaddingX == GhosttyWindowPadding(leading: 2))
Expand Down Expand Up @@ -94,6 +96,31 @@ struct GhosttyTerminalOptionsTests {
#expect(GhosttyTerminalOptionChange.scrollbackLimitBytes(25_000_000).configValues == ["25000000"])
}

@Test("adjust-cell-height reads percentages and pixels, and writes them back")
func cellHeightSpellings() {
func cellHeight(_ values: [String]) -> GhosttyCellHeightAdjustment {
GhosttyTerminalOptions(directives: ["adjust-cell-height": values]).cellHeight
}
#expect(cellHeight(["8%"]) == .percent(8))
#expect(cellHeight(["12.5%"]) == .percent(12.5))
#expect(cellHeight(["-15%"]) == .percent(-15))
// Ghostty rejects a space before the percent sign.
#expect(cellHeight(["-15 %"]) == .unadjusted)
#expect(cellHeight(["2"]) == .pixels(2))
#expect(cellHeight(["10%", "tall"]) == .percent(10))
#expect(cellHeight(["10%", ""]) == .unadjusted)
#expect(GhosttyCellHeightAdjustment.pixels(2).stepped(by: 1) == .pixels(3))
#expect(GhosttyCellHeightAdjustment.percent(8).stepped(by: -1) == .percent(6))
#expect(GhosttyCellHeightAdjustment.percent(12.5).configValue == "12.5%")
let hugePercent = GhosttyCellHeightAdjustment.percent(1e20)
#expect(GhosttyCellHeightAdjustment(configValue: hugePercent.configValue) == hugePercent)
#expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).key.rawValue == "adjust-cell-height")
#expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).configValues == ["12%"])
#expect(GhosttyTerminalOptionChange.fontThicken(true).key.rawValue == "font-thicken")
#expect(GhosttyTerminalOptionChange.fontThicken(true).configValues == ["true"])
#expect(GhosttyTerminalOptions(directives: ["font-thicken": ["true"]]).fontThicken)
}

@Test("A font change clears inherited families before setting its own")
func fontFamilyChangeResetsList() {
#expect(GhosttyTerminalOptionChange.fontFamilies(["JetBrains Mono"]).configValues == ["\"\"", "\"JetBrains Mono\""])
Expand Down Expand Up @@ -151,6 +178,8 @@ struct GhosttyTerminalOptionsTests {
let changes: [GhosttyTerminalOptionChange] = [
.fontFamilies(["SF Mono"]),
.fontSize(16),
.cellHeight(.percent(8)),
.fontThicken(true),
.cursorStyle(.underline),
.cursorBlinks(false),
.windowPaddingX(GhosttyWindowPadding(leading: 10)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import AppKit

extension NSFont {
/// Ghostty's built-in font, used when no `font-family` is set.
static let ghosttyBuiltInFamily = "JetBrains Mono"

/// The regular face of `family` at `size`, for previewing a terminal font
/// choice. `nil` means Ghostty's built-in font, which is shown with the
/// installed JetBrains Mono when present and the system monospaced font
/// otherwise. A family with no regular face falls back to its first member.
static func terminalPreview(family: String?, size: CGFloat) -> NSFont {
let manager = NSFontManager.shared
let resolvedFamily = family ?? ghosttyBuiltInFamily
if let font = manager.font(withFamily: resolvedFamily, traits: [], weight: 5, size: size) {
return font
}
if let member = manager.availableMembers(ofFontFamily: resolvedFamily)?.first,
let name = member.first as? String,
let font = NSFont(name: name, size: size) {
return font
}
return .monospacedSystemFont(ofSize: size, weight: .regular)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import CmuxFoundation
import Foundation
import Observation

/// The Ghostty option values Settings > Terminal shows, shared by the Font card
/// and the options card so an edit in one isn't undone by the other's refresh.
///
/// Each edit shows right away, is written to cmux's Ghostty config after a
/// short pause, and is then checked against a re-read of the effective values.
@MainActor
@Observable
final class TerminalGhosttyOptionsModel {
/// Coalesces stepper autorepeat and quick clicks into one write and reload.
private static let writeDelay: Duration = .milliseconds(250)

@ObservationIgnored private let hostActions: SettingsHostActions
@ObservationIgnored private let tasks = MainActorTaskStore<GhosttyTerminalOptionKey>()
/// Changes shown optimistically whose write hasn't finished yet, reapplied
/// over each re-read so one row's refresh doesn't undo another row's edit.
@ObservationIgnored private var pendingChanges: [GhosttyTerminalOptionKey: GhosttyTerminalOptionChange] = [:]
@ObservationIgnored private var isLoading = false

private(set) var options = GhosttyTerminalOptions.defaults
private(set) var hasLoaded = false
/// Installed fixed-pitch families, sorted; empty until loaded.
private(set) var monospacedFamilies: [String] = []
private(set) var saveFailed = false
/// Keys whose written value a later-loading config file overrides, with
/// that file's display path.
private(set) var overriddenKeys: [GhosttyTerminalOptionKey: String] = [:]

init(hostActions: SettingsHostActions) {
self.hostActions = hostActions
}

/// Reads the effective values and the installed monospaced families once.
func load() async {
guard !hasLoaded, !isLoading else { return }
isLoading = true
let families = Task.detached(priority: .utility) { MonospacedFontFamilies().load() }
options = await hostActions.terminalGhosttyOptions().options
hasLoaded = true
monospacedFamilies = await families.value
isLoading = false
}

/// Installed monospaced families, plus the configured family when it isn't
/// flagged fixed-pitch, so a picker always shows the current choice.
var fontFamilyChoices: [String] {
guard let current = options.fontFamily, !monospacedFamilies.contains(current) else {
return monospacedFamilies
}
return ([current] + monospacedFamilies).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}

/// Shows `change` right away, then writes it after a short pause (a newer
/// change to the same key replaces this task, so stepper autorepeat writes
/// once), and re-reads the effective values. When a later-loading config
/// file still overrides the key, the row names that file instead of
/// silently snapping back.
func apply(_ change: GhosttyTerminalOptionChange) {
let key = change.key
options = options.applying(change)
pendingChanges[key] = change
tasks.replaceOnMainActor(key) { [self] in
try? await Task.sleep(for: Self.writeDelay)
guard !Task.isCancelled else { return }
let saved = await hostActions.applyTerminalGhosttyOption(change)
guard !Task.isCancelled else { return }
pendingChanges[key] = nil
saveFailed = !saved
let snapshot = await hostActions.terminalGhosttyOptions()
overriddenKeys[key] = saved && !snapshot.options.reflects(change)
? snapshot.sourcePaths[key]
: nil
options = pendingChanges.values.reduce(snapshot.options) { $0.applying($1) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,34 @@ import Foundation

extension Array where Element == CuratedSettingEntry {
/// Search entries for the native Ghostty option rows in Settings > Terminal
/// (`TerminalGhosttyOptionsCard`). Each row anchors itself with the matching
/// (`TerminalFontCard` and `TerminalGhosttyOptionsCard`). Each row anchors itself with the matching
/// `setting:terminal:<id>` id, and the synonyms carry its Ghostty key.
static var terminalGhosttyOptionEntries: [CuratedSettingEntry] {
[
.init(
section: .terminal,
id: "font-family",
title: String(localized: "settings.terminal.ghostty.fontFamily", defaultValue: "Font"),
synonyms: "font-family font family typeface monospaced monospace terminal font ghostty nerd font"
synonyms: "font-family font family typeface monospaced monospace terminal font change font switch font preview ghostty nerd font"
),
.init(
section: .terminal,
id: "font-size",
title: String(localized: "settings.terminal.ghostty.fontSize", defaultValue: "Font Size"),
synonyms: "font-size terminal font size text size points pt bigger smaller zoom ghostty"
),
.init(
section: .terminal,
id: "adjust-cell-height",
title: String(localized: "settings.terminal.ghostty.lineHeight", defaultValue: "Line Height"),
synonyms: "adjust-cell-height line height line spacing leading cell height taller rows ghostty"
),
.init(
section: .terminal,
id: "font-thicken",
title: String(localized: "settings.terminal.ghostty.fontThicken", defaultValue: "Thicker Strokes"),
synonyms: "font-thicken thicken bold heavier weight stroke crisp font smoothing ghostty"
),
.init(
section: .terminal,
id: "cursor-style",
Expand Down
Loading
Loading