font size setting

This commit is contained in:
Ian Gulliver
2026-10-20 19:32:27 -07:00
parent f2188c53eb
commit adb9424e66
9 changed files with 95 additions and 4 deletions
+5 -1
View File
@@ -171,7 +171,11 @@ A Mac that runs the server but not the app is updated by `byre-update` (`mac/Sou
## Persistence ## Persistence
`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). Each load of the saved servers deletes every `ServerRecord` saved before `tailnetServersSince` in `Startup.swift` (on every Mac, through iCloud): those are `tailscale serve` origins from before byre had its own node, and servers are added again by their node's origin. Records saved later, on any Mac, are kept. CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. `Servers.sync` is the iCloud sync state, from the latest finished CloudKit setup, import and export, which Settings shows under iCloud sync and every connection reports to its server (`docs/operations.md`, "Storage"). Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`). `Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts"), `VisitRecord` (one per task: when it was last selected, read by the task switcher) and `PrefsRecord` (the user's font size, see "Font size"). Each load of the saved servers deletes every `ServerRecord` saved before `tailnetServersSince` in `Startup.swift` (on every Mac, through iCloud): those are `tailscale serve` origins from before byre had its own node, and servers are added again by their node's origin. Records saved later, on any Mac, are kept. CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `PrefsRecord`, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. `Servers.sync` is the iCloud sync state, from the latest finished CloudKit setup, import and export, which Settings shows under iCloud sync and every connection reports to its server (`docs/operations.md`, "Storage"). Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`).
## Font size
Settings' Font size stepper sets the body text size, within `FontSize.range`, defaulting to `PrefsRecord.defaultFontSize`. It is one value for every server and every Mac: the newest `PrefsRecord`, which `FontSize` in `Fonts.swift` reads at launch and after each iCloud import, and which the stepper writes. Call sites give their sizes for a 13-point body (`Fonts.font(13)`, `Fonts.font(11)` for captions), and `Fonts` shifts every one by the chosen size minus 13, so captions, headings, Markdown, diagrams and the terminal move together. `Fonts.font` and `Fonts.ns` read `FontSize.shared` on the main actor, so Observation re-renders every view that drew text when it changes; the terminal sets its new font in `updateNSView`.
## Gaps in SwiftUI ## Gaps in SwiftUI
+1 -1
View File
@@ -176,7 +176,7 @@ Clients reach byre over HTTP/2 on its tailnet node.
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker in its compact toolbar, which has the Away button at its right end (see "Away"). Closing the window leaves the app running for notifications; ⌘Q quits it. **Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker in its compact toolbar, which has the Away button at its right end (see "Away"). Closing the window leaves the app running for notifications; ⌘Q quits it.
**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): each saved server's origin and window colour, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`). Production never creates fields itself: a record carrying an attribute its schema lacks is rejected, and that one rejection stalls every later upload from that Mac (its `ANSCKEVENT` table shows exports failing with `CKErrorDomain` 2), until the app relaunches after the schema is fixed. The app never writes to Development either, so a change to the SwiftData models must be added there by hand before it is deployed or released: `xcrun cktool export-schema --team-id <team> --container-id iCloud.run.fc.byre.mac --environment development --output-file <file>` (`<team>` is `TeamIdentifier` in `security cms -D -i mac/byre.provisionprofile`), add each new attribute to its `CD_<Model>` record type as `CD_<attribute>` (`Int` is `INT64 QUERYABLE SORTABLE`), `xcrun cktool import-schema` the file back to Development, then Deploy Schema Changes in the CloudKit console; `export-schema --environment production` shows what Production has, and `mac/package.sh` refuses to build while it lacks a model attribute. `cktool` needs a management token saved with `xcrun cktool save-token --type management --method file` (the console's Tokens & Keys page makes one). The CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. The app watches every finished CloudKit setup, import and export (`NSPersistentCloudKitContainer.eventChangedNotification`, in `Servers` in `mac/Sources/Byre/Startup.swift`) and keeps the latest of each kind: succeeded, or failed with the error (for a partial failure, the first record's error and how many records failed), and when. Its sync state is the newest failed one while any kind's latest failed, so a successful import doesn't hide exports that still fail, and otherwise the newest. A failure is logged with the error in the clear, which CoreData's own log lines redact. The Settings tab shows the state under iCloud sync, a failure in the danger colour, and the app reports it to every server it is connected to with `PUT /api/clients/sync` (`ClientSync`: `ok`, `error`, `at`), on each change and each time its `/api/clients/connect` WebSocket connects or reconnects, so a report lost while the server was unreachable is sent again; the server keeps it in the client registry and shows it as the client's `sync` in the status (see "Status"), so the Status tab's Clients table and `list_clients` show a Mac whose sync is failing. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Forget Server and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. **Storage.** The app keeps four kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): each saved server's origin and window colour, when each task was last selected, composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"), and the user's font size (`PrefsRecord`, see "Font size" in `docs/macos.md`). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`). Production never creates fields itself: a record carrying an attribute its schema lacks is rejected, and that one rejection stalls every later upload from that Mac (its `ANSCKEVENT` table shows exports failing with `CKErrorDomain` 2), until the app relaunches after the schema is fixed. The app never writes to Development either, so a change to the SwiftData models must be added there by hand before it is deployed or released: `xcrun cktool export-schema --team-id <team> --container-id iCloud.run.fc.byre.mac --environment development --output-file <file>` (`<team>` is `TeamIdentifier` in `security cms -D -i mac/byre.provisionprofile`), add each new attribute to its `CD_<Model>` record type as `CD_<attribute>` (`Int` is `INT64 QUERYABLE SORTABLE`), and each new model as a `CD_<Model>` record type shaped like the others (`CD_entityName`, the `___` system fields and the grants), `xcrun cktool import-schema` the file back to Development, then Deploy Schema Changes in the CloudKit console; `export-schema --environment production` shows what Production has, and `mac/package.sh` refuses to build while it lacks a model attribute. `cktool` needs a management token saved with `xcrun cktool save-token --type management --method file` (the console's Tokens & Keys page makes one). The CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. The app watches every finished CloudKit setup, import and export (`NSPersistentCloudKitContainer.eventChangedNotification`, in `Servers` in `mac/Sources/Byre/Startup.swift`) and keeps the latest of each kind: succeeded, or failed with the error (for a partial failure, the first record's error and how many records failed), and when. Its sync state is the newest failed one while any kind's latest failed, so a successful import doesn't hide exports that still fail, and otherwise the newest. A failure is logged with the error in the clear, which CoreData's own log lines redact. The Settings tab shows the state under iCloud sync, a failure in the danger colour, and the app reports it to every server it is connected to with `PUT /api/clients/sync` (`ClientSync`: `ok`, `error`, `at`), on each change and each time its `/api/clients/connect` WebSocket connects or reconnects, so a report lost while the server was unreachable is sent again; the server keeps it in the client registry and shows it as the client's `sync` in the status (see "Status"), so the Status tab's Clients table and `list_clients` show a Mac whose sync is failing. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Forget Server and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
## Screen and app control ## Screen and app control
+1
View File
@@ -1638,6 +1638,7 @@ private struct RunSummary: View {
} }
} }
@MainActor
private func styled(_ parts: [(String, CGFloat, Color?)], size: CGFloat = 12) -> AttributedString { private func styled(_ parts: [(String, CGFloat, Color?)], size: CGFloat = 12) -> AttributedString {
var out = AttributedString() var out = AttributedString()
for (text, weight, color) in parts { for (text, weight, color) in parts {
+3
View File
@@ -277,10 +277,12 @@ struct DiagramLayout {
let size: CGSize let size: CGSize
} }
@MainActor
private var labelFont: CTFont { private var labelFont: CTFont {
Fonts.ns(13) as CTFont Fonts.ns(13) as CTFont
} }
@MainActor
private var edgeFont: CTFont { private var edgeFont: CTFont {
Fonts.ns(12) as CTFont Fonts.ns(12) as CTFont
} }
@@ -296,6 +298,7 @@ func textSize(_ text: String, _ font: CTFont) -> CGSize {
return CGSize(width: ceil(width), height: ceil(lineHeight * CGFloat(lines.count))) return CGSize(width: ceil(width), height: ceil(lineHeight * CGFloat(lines.count)))
} }
@MainActor
func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout {
let ids = chart.order let ids = chart.order
var succ: [String: [String]] = [:] var succ: [String: [String]] = [:]
+39
View File
@@ -1,7 +1,41 @@
import CoreText import CoreText
import Foundation
import Synchronization import Synchronization
import SwiftData
import SwiftUI import SwiftUI
@MainActor
@Observable
final class FontSize {
static let shared = FontSize()
static let range = 9...20
private(set) var body = PrefsRecord.defaultFontSize
private init() {
load()
}
func load() {
body = record()?.fontSize ?? PrefsRecord.defaultFontSize
}
func set(_ size: Int) {
if let r = record() {
r.fontSize = size
r.saved = Date()
} else {
persistence.insert(PrefsRecord(fontSize: size))
}
saveRecords()
body = size
}
private func record() -> PrefsRecord? {
fetchRecords(FetchDescriptor<PrefsRecord>(sortBy: [SortDescriptor(\.saved, order: .reverse)])).first
}
}
enum Fonts { enum Fonts {
private static func file(_ subset: String, _ italic: Bool, _ weight: CGFloat) -> CTFontDescriptor { private static func file(_ subset: String, _ italic: Bool, _ weight: CGFloat) -> CTFontDescriptor {
let url = Bundle.main.resourceURL!.appending(path: "fonts/atkinson-hyperlegible-mono-\(subset)-wght-\(italic ? "italic" : "normal").woff2") let url = Bundle.main.resourceURL!.appending(path: "fonts/atkinson-hyperlegible-mono-\(subset)-wght-\(italic ? "italic" : "normal").woff2")
@@ -17,7 +51,10 @@ enum Fonts {
private static let fonts = Mutex<[String: CTFont]>([:]) private static let fonts = Mutex<[String: CTFont]>([:])
// Call sites give sizes relative to a 13pt body; the user's font size shifts them all.
@MainActor
private static func cached(_ size: CGFloat, _ weight: CGFloat, _ italic: Bool) -> CTFont { private static func cached(_ size: CGFloat, _ weight: CGFloat, _ italic: Bool) -> CTFont {
let size = size + CGFloat(FontSize.shared.body - 13)
let key = "\(size) \(weight) \(italic)" let key = "\(size) \(weight) \(italic)"
return fonts.withLock { fonts in return fonts.withLock { fonts in
if let f = fonts[key] { if let f = fonts[key] {
@@ -29,10 +66,12 @@ enum Fonts {
} }
} }
@MainActor
static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font { static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font {
Font(cached(size, weight, italic)) Font(cached(size, weight, italic))
} }
@MainActor
static func ns(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> NSFont { static func ns(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> NSFont {
cached(size, weight, italic) as NSFont cached(size, weight, italic) as NSFont
} }
+14 -1
View File
@@ -38,8 +38,21 @@ final class VisitRecord {
} }
} }
@Model
final class PrefsRecord {
static let defaultFontSize = 12
var fontSize = PrefsRecord.defaultFontSize
var saved = Date.distantPast
init(fontSize: Int) {
self.fontSize = fontSize
saved = Date()
}
}
private let container: ModelContainer = { private let container: ModelContainer = {
let schema = Schema([ServerRecord.self, DraftRecord.self, VisitRecord.self]) let schema = Schema([ServerRecord.self, DraftRecord.self, VisitRecord.self, PrefsRecord.self])
let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac", directoryHint: .isDirectory) let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac", directoryHint: .isDirectory)
do { do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+27 -1
View File
@@ -27,6 +27,11 @@ struct SettingsView: View {
.foregroundStyle(Palette.muted) .foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
ServerColorPicker() ServerColorPicker()
heading("Font size")
Text("The size of body text, on every Mac you use. Other text grows and shrinks with it.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
FontSizeStepper()
heading("Notifications") heading("Notifications")
DeviceNotifications() DeviceNotifications()
PrefsBox() PrefsBox()
@@ -87,12 +92,33 @@ private struct ServerColorPicker: View {
} }
} }
private struct FontSizeStepper: View {
var body: some View {
let fonts = FontSize.shared
HStack(spacing: 8) {
Button("−") {
fonts.set(fonts.body - 1)
}
.buttonStyle(WebButtonStyle())
.disabled(fonts.body <= FontSize.range.lowerBound)
Text("\(fonts.body) pt")
.monospacedDigit()
Button("+") {
fonts.set(fonts.body + 1)
}
.buttonStyle(WebButtonStyle())
.disabled(fonts.body >= FontSize.range.upperBound)
}
.padding(.top, 8)
}
}
private struct SyncState: View { private struct SyncState: View {
var body: some View { var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { _ in TimelineView(.periodic(from: .now, by: 30)) { _ in
if let sync = Servers.shared.sync { if let sync = Servers.shared.sync {
if sync.ok { if sync.ok {
Text("This Mac shares its servers, window colours, drafts and task visits through iCloud. Last synced \(ago(sync.at)).") Text("This Mac shares its servers, window colours, font size, drafts and task visits through iCloud. Last synced \(ago(sync.at)).")
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} else { } else {
Text("iCloud sync is failing on this Mac, so changes made here aren't reaching your other Macs. Failed \(ago(sync.at)): \(sync.error ?? "")") Text("iCloud sync is failing on this Mac, so changes made here aren't reaching your other Macs. Failed \(ago(sync.at)): \(sync.error ?? "")")
+1
View File
@@ -34,6 +34,7 @@ final class Servers {
connection.store?.drafts?.reload() connection.store?.drafts?.reload()
} }
load() load()
FontSize.shared.load()
} }
} }
} }
+4
View File
@@ -90,6 +90,10 @@ struct TerminalHost: NSViewRepresentable {
} }
func updateNSView(_ nsView: TerminalView, context: Context) { func updateNSView(_ nsView: TerminalView, context: Context) {
let font = Fonts.ns(13)
if nsView.font !== font {
nsView.font = font
}
if context.coordinator.scheme == scheme { if context.coordinator.scheme == scheme {
return return
} }