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
2 changes: 1 addition & 1 deletion Resources/Skills/supacode-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ supacode repo worktree-new [-r <id>] [--branch <name>] [--base <ref>] [--upstrea
### Settings

```
supacode settings [<section>] # Open settings (general|notifications|worktrees|developer|shortcuts|scripts|updates|github).
supacode settings [<section>] # Open settings (general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github).
supacode settings repo [-r <id>] # Open repository settings.
supacode settings repo scripts [-r <id>] # Open repository Scripts settings.
```
Expand Down
2 changes: 1 addition & 1 deletion Resources/Skills/supacode-deeplinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ supacode://repo/<repo_id>/worktree/new?branch=<name>&base=<ref>&upstream=<ref>&f

```
supacode://settings # Open settings.
supacode://settings/<section> # general|notifications|worktrees|developer|shortcuts|scripts|updates|github.
supacode://settings/<section> # general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github.
supacode://settings/repo/<repo_id> # Open repository settings.
supacode://settings/repo/<repo_id>/scripts # Open repository Scripts settings.
```
6 changes: 5 additions & 1 deletion SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public struct SettingsFeature {
public var remoteSessionPersistenceEnabled: Bool
public var appVisibility: AppVisibility
public var terminalHibernationEnabled: Bool
public var chromeTextSize: ChromeTextSize
public var cliInstallState = CLIInstallState.checking
/// Installed editors in menu order, resolved once off the picker's body.
public var installedOpenActions: [OpenWorktreeAction]
Expand Down Expand Up @@ -158,6 +159,7 @@ public struct SettingsFeature {
remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled
appVisibility = settings.appVisibility
terminalHibernationEnabled = settings.terminalHibernationEnabled
chromeTextSize = settings.chromeTextSize
defaultWorktreeBaseDirectoryPath =
SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? ""
}
Expand Down Expand Up @@ -200,7 +202,8 @@ public struct SettingsFeature {
terminateSessionsOnQuit: terminateSessionsOnQuit,
remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled,
appVisibility: appVisibility,
terminalHibernationEnabled: terminalHibernationEnabled
terminalHibernationEnabled: terminalHibernationEnabled,
chromeTextSize: chromeTextSize
)
}
}
Expand Down Expand Up @@ -340,6 +343,7 @@ public struct SettingsFeature {
state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled
state.appVisibility = normalizedSettings.appVisibility
state.terminalHibernationEnabled = normalizedSettings.terminalHibernationEnabled
state.chromeTextSize = normalizedSettings.chromeTextSize
state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? ""
state.syncGlobalDefaults(from: normalizedSettings)
synchronizeRepositorySelection(for: &state)
Expand Down
30 changes: 30 additions & 0 deletions SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import ComposableArchitecture
import SupacodeSettingsShared
import SwiftUI

/// Accessibility pane. Holds the chrome text size, which is separate from the
/// terminal's own font zoom.
public struct AccessibilitySettingsView: View {
@Bindable var store: StoreOf<SettingsFeature>

public init(store: StoreOf<SettingsFeature>) {
self.store = store
}

public var body: some View {
Form {
Section {
Picker(selection: $store.chromeTextSize) {
ForEach(ChromeTextSize.allCases) { size in
Text(size.label).tag(size)
}
} label: {
Text("Text size")
Text("Sizes the sidebar, tab bar, toolbars, and Settings text. The terminal has its own font size.")
}
}
}
.formStyle(.grouped)
.navigationTitle("Accessibility")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct AppVisibilityOptionCardView: View {
)
}
Text(visibility.title)
.font(.callout)
.appFont(.callout)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.foregroundStyle(isSelected ? .primary : .secondary)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct AppearanceOptionCardView: View {
)
}
Text(mode.title)
.font(.callout)
.appFont(.callout)
.foregroundStyle(isSelected ? .primary : .secondary)
}
}
Expand Down
3 changes: 1 addition & 2 deletions SupacodeSettingsFeature/Views/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ public struct AppearanceSettingsView: View {
private struct BetaBadge: View {
var body: some View {
Text("Beta")
.font(.caption2)
.fontWeight(.semibold)
.appFont(.caption2, weight: .semibold)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public struct GlobalScriptsSettingsView: View {
} header: {
Label {
Text("\(script.displayName) Script")
.font(.body)
.appFont(.body)
.bold()
} icon: {
Image(systemName: script.resolvedSystemImage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public struct RepositoryScriptsSettingsView: View {
} header: {
Label {
Text("\(script.displayName) Script")
.font(.body)
.appFont(.body)
.bold()
} icon: {
Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color)
Expand Down Expand Up @@ -134,11 +134,11 @@ private struct LifecycleScriptSection: View {
Label {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.body)
.appFont(.body)
.bold()
.lineLimit(1)
Text(subtitle)
.font(.footnote)
.appFont(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Expand Down
2 changes: 1 addition & 1 deletion SupacodeSettingsFeature/Views/RepositorySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public struct RepositorySettingsView: View {
.disabled(store.isBareRepository)
if store.isBareRepository {
Text("Copy flags are ignored for bare repositories.")
.font(.footnote)
.appFont(.footnote)
.foregroundStyle(.tertiary)
}
TextField(
Expand Down
1 change: 1 addition & 0 deletions SupacodeSettingsFeature/Views/SettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

public enum SettingsSection: Hashable {
case general
case accessibility
case notifications
case worktree
case developer
Expand Down
38 changes: 38 additions & 0 deletions SupacodeSettingsShared/Models/ChromeTextSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// Size of the app chrome's text: the sidebar, tab bar, toolbars, and Settings.
/// A fixed set of sizes rather than a free-form scale, so the layout only has to
/// hold at three known points.
///
/// The size is applied by resolving each semantic font explicitly (see
/// `View.appFont`), because macOS SwiftUI does not resize text in response to
/// `dynamicTypeSize`. The Ghostty terminal surface is NSView-backed with its own
/// font system and is unaffected.
public nonisolated enum ChromeTextSize: String, CaseIterable, Codable, Identifiable, Sendable {
case standard
case large
case extraLarge

public static let `default` = ChromeTextSize.standard

public var id: String { rawValue }

public var label: String {
switch self {
case .standard: "Default"
case .large: "Large"
case .extraLarge: "Extra Large"
}
}

/// Multiplier applied to each semantic font's system point size. macOS has no
/// Dynamic Type step table to inherit, so the two steps are chosen to land
/// near where iOS's `.xLarge` and `.xxLarge` sit.
public var scale: Double {
switch self {
case .standard: 1.0
case .large: 1.15
case .extraLarge: 1.3
}
}
}
17 changes: 15 additions & 2 deletions SupacodeSettingsShared/Models/GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
/// Beta: hidden terminal tabs release their renderer after a few minutes of
/// inactivity and reconnect when viewed. On by default.
public var terminalHibernationEnabled: Bool
/// Accessibility size for the app chrome's text. Drives the scale published at
/// each window root. Defaults to the unmodified system size.
public var chromeTextSize: ChromeTextSize

public static let `default` = GlobalSettings(
appearanceMode: .dark,
Expand Down Expand Up @@ -139,7 +142,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
confirmCloseSurface: true,
terminateSessionsOnQuit: false,
remoteSessionPersistenceEnabled: true,
appVisibility: .dockAndMenuBar
appVisibility: .dockAndMenuBar,
chromeTextSize: .default
)

public init(
Expand Down Expand Up @@ -177,7 +181,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
terminateSessionsOnQuit: Bool = false,
remoteSessionPersistenceEnabled: Bool = true,
appVisibility: AppVisibility = .dockAndMenuBar,
terminalHibernationEnabled: Bool = true
terminalHibernationEnabled: Bool = true,
chromeTextSize: ChromeTextSize = .default
) {
self.appearanceMode = appearanceMode
self.defaultEditorID = defaultEditorID
Expand Down Expand Up @@ -214,6 +219,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled
self.appVisibility = appVisibility
self.terminalHibernationEnabled = terminalHibernationEnabled
self.chromeTextSize = chromeTextSize
}

/// Keys for reading renamed settings fields that no longer
Expand Down Expand Up @@ -388,5 +394,12 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
terminalHibernationEnabled =
try container.decodeIfPresent(Bool.self, forKey: .terminalHibernationEnabled)
?? Self.default.terminalHibernationEnabled
// Old settings files predate this key; they migrate to the system size. An
// unrecognized value falls back the same way rather than throwing, which
// would reset the whole file to defaults.
chromeTextSize =
((try? container.decodeIfPresent(String.self, forKey: .chromeTextSize)) ?? nil)
.flatMap(ChromeTextSize.init(rawValue:))
?? Self.default.chromeTextSize
}
}
137 changes: 137 additions & 0 deletions SupacodeSettingsShared/Support/UITextScale.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import AppKit
import SwiftUI

/// Environment carrier for the app-wide UI text scale (1.0 = system default).
/// Set at each window root from `GlobalSettings.chromeTextSize`; read by
/// `View.appFont(_:)`. Using an explicit scale (rather than `dynamicTypeSize`)
/// is required because macOS SwiftUI does not resize text for Dynamic Type.
///
/// Lives in the shared module so every UI module (app + settings feature) routes
/// its chrome text through the same helper.
private struct UITextScaleKey: EnvironmentKey {
static let defaultValue: Double = 1.0
}

extension EnvironmentValues {
public var uiTextScale: Double {
get { self[UITextScaleKey.self] }
set { self[UITextScaleKey.self] = newValue }
}
}

/// Base point sizes and default weights for the semantic text styles, read from
/// the live system fonts so scaled sizes track the platform instead of a
/// hardcoded table. Read once: macOS resolves the text-style table at launch and
/// does not change it while the app runs, and the scaled path would otherwise
/// hit AppKit on every body evaluation of every scaled view.
private enum AppFontMetrics {
private static let pointSizes: [Font.TextStyle: CGFloat] = {
let styles: [Font.TextStyle] = [
.largeTitle, .title, .title2, .title3, .headline, .subheadline,
.body, .callout, .footnote, .caption, .caption2,
]
return Dictionary(
uniqueKeysWithValues: styles.map {
($0, NSFont.preferredFont(forTextStyle: nsTextStyle(for: $0)).pointSize)
}
)
}()

static func pointSize(for style: Font.TextStyle) -> CGFloat {
pointSizes[style] ?? NSFont.preferredFont(forTextStyle: .body).pointSize
}

/// The semantic style's default weight, preserved when the caller doesn't
/// override it. Only `.headline` deviates from regular on macOS.
static func defaultWeight(for style: Font.TextStyle) -> Font.Weight {
style == .headline ? .semibold : .regular
}

private static func nsTextStyle(for style: Font.TextStyle) -> NSFont.TextStyle {
switch style {
case .largeTitle: .largeTitle
case .title: .title1
case .title2: .title2
case .title3: .title3
case .headline: .headline
case .subheadline: .subheadline
case .body: .body
case .callout: .callout
case .footnote: .footnote
case .caption: .caption1
case .caption2: .caption2
@unknown default: .body
}
}
}

private struct AppFontModifier: ViewModifier {
@Environment(\.uiTextScale) private var scale
let style: Font.TextStyle
let weight: Font.Weight?
let monospaced: Bool

func body(content: Content) -> some View {
content.font(resolvedFont)
}

private var resolvedFont: Font {
// At 1.0× use the exact semantic font, so Default leaves text untouched.
if scale == 1.0 {
var font = Font.system(style)
if let weight { font = font.weight(weight) }
if monospaced { font = font.monospaced() }
return font
}
// Rounded: a fractional point size gives a fractional line height, which
// drifts baselines across the sidebar's dense rows.
let scaledSize = (AppFontMetrics.pointSize(for: style) * scale).rounded()
let font = Font.system(size: scaledSize, design: monospaced ? .monospaced : .default)
return font.weight(weight ?? AppFontMetrics.defaultWeight(for: style))
}
}

/// Scales text that has no font of its own. `List` styles its own section
/// headers and SwiftUI does not expose the font it resolved, so there is nothing
/// to scale in place — `base` stands in for it. At 1.0× the view is left alone,
/// which keeps the platform header styling as it ships.
private struct AppFontInheritedModifier: ViewModifier {
@Environment(\.uiTextScale) private var scale
let base: Font.TextStyle
let weight: Font.Weight?

func body(content: Content) -> some View {
if scale == 1.0 {
content
} else {
content.font(
Font.system(size: (AppFontMetrics.pointSize(for: base) * scale).rounded())
.weight(weight ?? AppFontMetrics.defaultWeight(for: base))
)
}
}
}

extension View {
/// Publishes the chrome text size to descendants. Applied at each window root;
/// text opts in with `.appFont(_:)`.
public func appChromeTextSize(_ size: ChromeTextSize) -> some View {
environment(\.uiTextScale, size.scale)
}

/// Applies a semantic text style that honors the chrome text size. Use this in
/// place of `.font(.body)` etc. for chrome text that should follow the user's
/// chosen size. `weight` overrides the style's default weight; `monospaced`
/// picks the monospaced design.
public func appFont(_ style: Font.TextStyle, weight: Font.Weight? = nil, monospaced: Bool = false) -> some View {
modifier(AppFontModifier(style: style, weight: weight, monospaced: monospaced))
}

/// Scales text whose font comes from an enclosing container rather than from
/// this view — the sidebar's `Section` headers. At 1.0× the container's
/// styling is left in place; `base` only sets the point size the text grows
/// from above that.
public func appFontInheriting(_ base: Font.TextStyle, weight: Font.Weight? = nil) -> some View {
modifier(AppFontInheritedModifier(base: base, weight: weight))
}
}
Loading
Loading