Commit 57e588172b

57e588172b2b7c02d66566851408feb1f85ec08d

parent: d151e20a46

Unsigned

cmc <hello@cleberg.net> · 2026-04-01 18:50 UTC

Rewrite file viewer with pinned gutter, syntax highlighting, and toolbar

Replace the SwiftUI-only CodeFileTextView with a UIViewRepresentable backed
by CodeFileUIView. The previous approach could not support a fixed line number
gutter while independently scrolling code content horizontally.

Changes:
- Gutter is pinned outside the horizontal scroll view and never scrolls left
- Vertical scroll is handled by a single outer UIScrollView shared by both
  the gutter and code content, keeping them in sync
- Line numbers are right-aligned in a fixed-width gutter calculated from
  the total line count
- Syntax highlighting via Splash for .swift files, with plain monospaced
  rendering as fallback for unsupported file types
- Wrap toggle persisted via AppStorage (wrapRepositoryFileLines)
- Bottom toolbar replaces the floating Share button, adding Copy All and
  Wrap toggle actions
- Copy All shows a brief checkmark confirmation that resets after 2 seconds
- Font fixed at SFMono-Regular 12pt, not scaled with Dynamic Type

Implements: https://todo.sr.ht/~ccleberg/Hutch/7

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +17
@@ -16,6 +16,7 @@
16 8BE09E812F7B2CC300F88693 /* styles.css in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7D2F7B2CC300F88693 /* styles.css */; }; 16 8BE09E812F7B2CC300F88693 /* styles.css in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7D2F7B2CC300F88693 /* styles.css */; };
17 8BE09E822F7B2CC300F88693 /* privacy.html in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7B2F7B2CC300F88693 /* privacy.html */; }; 17 8BE09E822F7B2CC300F88693 /* privacy.html in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7B2F7B2CC300F88693 /* privacy.html */; };
18 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; 18 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
19 8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; };
19/* End PBXBuildFile section */ 20/* End PBXBuildFile section */
20 21
21/* Begin PBXContainerItemProxy section */ 22/* Begin PBXContainerItemProxy section */
@@ -116,6 +117,7 @@
116 buildActionMask = 2147483647; 117 buildActionMask = 2147483647;
117 files = ( 118 files = (
118 8BF100032F9A000100000001 /* Markdown in Frameworks */, 119 8BF100032F9A000100000001 /* Markdown in Frameworks */,
120 8BF100052F9A000100000002 /* Splash in Frameworks */,
119 ); 121 );
120 runOnlyForDeploymentPostprocessing = 0; 122 runOnlyForDeploymentPostprocessing = 0;
121 }; 123 };
@@ -198,6 +200,7 @@
198 name = Hutch; 200 name = Hutch;
199 packageProductDependencies = ( 201 packageProductDependencies = (
200 8BF100022F9A000100000001 /* Markdown */, 202 8BF100022F9A000100000001 /* Markdown */,
203 8BF100042F9A000100000002 /* Splash */,
201 ); 204 );
202 productName = Hutch; 205 productName = Hutch;
203 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; 206 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -279,6 +282,7 @@
279 minimizedProjectReferenceProxies = 1; 282 minimizedProjectReferenceProxies = 1;
280 packageReferences = ( 283 packageReferences = (
281 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */, 284 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
285 8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */,
282 ); 286 );
283 preferredProjectObjectVersion = 77; 287 preferredProjectObjectVersion = 77;
284 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; 288 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
@@ -710,6 +714,14 @@
710 minimumVersion = 0.7.3; 714 minimumVersion = 0.7.3;
711 }; 715 };
712 }; 716 };
717 8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */ = {
718 isa = XCRemoteSwiftPackageReference;
719 repositoryURL = "https://github.com/JohnSundell/Splash";
720 requirement = {
721 kind = upToNextMajorVersion;
722 minimumVersion = 0.16.0;
723 };
724 };
713/* End XCRemoteSwiftPackageReference section */ 725/* End XCRemoteSwiftPackageReference section */
714 726
715/* Begin XCSwiftPackageProductDependency section */ 727/* Begin XCSwiftPackageProductDependency section */
@@ -718,6 +730,11 @@
718 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */; 730 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
719 productName = Markdown; 731 productName = Markdown;
720 }; 732 };
733 8BF100042F9A000100000002 /* Splash */ = {
734 isa = XCSwiftPackageProductDependency;
735 package = 8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */;
736 productName = Splash;
737 };
721/* End XCSwiftPackageProductDependency section */ 738/* End XCSwiftPackageProductDependency section */
722 }; 739 };
723 rootObject = 8B4B28C92F6704280045FA19 /* Project object */; 740 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,6 +1,15 @@
1{ 1{
2 "originHash" : "b809819531d430dc5b7a0fb1fb5538d0321c5d541eed52a9990f37c38dd0f98a", 2 "originHash" : "65542d8a5fbbad8dd7d07c8901c1cc48b3aba12cef8da1f062bd28d9b3a6ba50",
3 "pins" : [ 3 "pins" : [
4 {
5 "identity" : "splash",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/JohnSundell/Splash",
8 "state" : {
9 "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
10 "version" : "0.16.0"
11 }
12 },
4 { 13 {
5 "identity" : "swift-cmark", 14 "identity" : "swift-cmark",
6 "kind" : "remoteSourceControl", 15 "kind" : "remoteSourceControl",
Hutch/App/AppStorageKeys.swift +1
@@ -2,4 +2,5 @@
2enum AppStorageKeys { 2enum AppStorageKeys {
3 static let swipeActionsEnabled = "swipeActionsEnabled" 3 static let swipeActionsEnabled = "swipeActionsEnabled"
4 static let activeAccountID = "activeAccountID" 4 static let activeAccountID = "activeAccountID"
5 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
5} 6}
Hutch/Views/Repositories/FileTreeView.swift +516 −50
@@ -1,4 +1,8 @@
1import Splash
1import SwiftUI 2import SwiftUI
3import UIKit
4
5private typealias ViewColor = SwiftUI.Color
2 6
3struct FileTreeView: View { 7struct FileTreeView: View {
4 let repository: RepositorySummary 8 let repository: RepositorySummary
@@ -9,7 +13,7 @@ struct FileTreeView: View {
9 var body: some View { 13 var body: some View {
10 Group { 14 Group {
11 if let viewModel { 15 if let viewModel {
12 FileTreeContentView(repository: repository, viewModel: viewModel) 16 FileTreeContentView(viewModel: viewModel)
13 } else { 17 } else {
14 SRHTLoadingStateView(message: "Loading files…") 18 SRHTLoadingStateView(message: "Loading files…")
15 } 19 }
@@ -33,10 +37,14 @@ struct FileTreeView: View {
33// MARK: - Content View 37// MARK: - Content View
34 38
35private struct FileTreeContentView: View { 39private struct FileTreeContentView: View {
36 let repository: RepositorySummary
37 let viewModel: FileTreeViewModel 40 let viewModel: FileTreeViewModel
38 41
42 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
39 @State private var showRefPicker = false 43 @State private var showRefPicker = false
44 @State private var showFileShareSheet = false
45 @State private var showShareUnavailableAlert = false
46 @State private var didCopyFileContents = false
47 @State private var copyResetTask: Task<Void, Never>?
40 48
41 var body: some View { 49 var body: some View {
42 VStack(spacing: 0) { 50 VStack(spacing: 0) {
@@ -64,20 +72,11 @@ private struct FileTreeContentView: View {
64 get: { viewModel.error }, 72 get: { viewModel.error },
65 set: { viewModel.error = $0 } 73 set: { viewModel.error = $0 }
66 )) 74 ))
67 .refreshable { 75 .onChange(of: viewModel.viewingEntry?.name) { _, _ in
68 await viewModel.loadRootTree() 76 resetCopyConfirmation()
69 } 77 }
70 } 78 }
71 79
72 private var shareURL: URL? {
73 guard let viewingEntry = viewModel.viewingEntry else { return nil }
74 return SRHTWebURL.file(
75 repository: repository,
76 revspec: viewModel.revspec,
77 path: currentFilePath(for: viewingEntry)
78 )
79 }
80
81 private var revspecLabel: String { 80 private var revspecLabel: String {
82 let revspec = viewModel.revspec 81 let revspec = viewModel.revspec
83 if revspec == "HEAD" { 82 if revspec == "HEAD" {
@@ -138,13 +137,6 @@ private struct FileTreeContentView: View {
138 .background(.bar) 137 .background(.bar)
139 } 138 }
140 139
141 private func currentFilePath(for entry: TreeEntry) -> String {
142 let directoryComponents = viewModel.navStack
143 .dropFirst()
144 .map(\.name)
145 return (directoryComponents + [entry.name]).joined(separator: "/")
146 }
147
148 // MARK: - Content Area 140 // MARK: - Content Area
149 141
150 @ViewBuilder 142 @ViewBuilder
@@ -178,6 +170,33 @@ private struct FileTreeContentView: View {
178 } 170 }
179 } 171 }
180 172
173 private func shareFileContents(_ text: String) {
174 if text.isEmpty {
175 showShareUnavailableAlert = true
176 } else {
177 showFileShareSheet = true
178 }
179 }
180
181 private func copyFileContents(_ text: String) {
182 UIPasteboard.general.string = text
183 didCopyFileContents = true
184 copyResetTask?.cancel()
185 copyResetTask = Task {
186 try? await Task.sleep(for: .seconds(2))
187 guard !Task.isCancelled else { return }
188 await MainActor.run {
189 didCopyFileContents = false
190 }
191 }
192 }
193
194 private func resetCopyConfirmation() {
195 copyResetTask?.cancel()
196 copyResetTask = nil
197 didCopyFileContents = false
198 }
199
181 // MARK: - File Content View 200 // MARK: - File Content View
182 201
183 @ViewBuilder 202 @ViewBuilder
@@ -216,36 +235,34 @@ private struct FileTreeContentView: View {
216 } 235 }
217 } 236 }
218 .listStyle(.plain) 237 .listStyle(.plain)
238 .refreshable {
239 await viewModel.loadRootTree()
240 }
219 } 241 }
220 242
221 // MARK: - Text Blob 243 // MARK: - Text Blob
222 244
223 @ViewBuilder 245 @ViewBuilder
224 private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View { 246 private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View {
247 let text = blob.text ?? ""
248
225 VStack(spacing: 0) { 249 VStack(spacing: 0) {
226 HStack { 250 CodeFileTextView(
227 Spacer() 251 text: text,
228 SRHTShareButton(url: shareURL, target: .file) { 252 fileName: entry.name,
229 Label("Share File", systemImage: "square.and.arrow.up") 253 wrapLines: wrapRepositoryFileLines
230 } 254 )
231 .buttonStyle(.bordered) 255 }
232 } 256 .safeAreaInset(edge: .bottom, spacing: 0) {
233 .padding(.horizontal) 257 fileActionToolbar(text: text)
234 .padding(.top, 12) 258 }
235 259 .sheet(isPresented: $showFileShareSheet) {
236 GeometryReader { geometry in 260 FileContentShareSheet(activityItems: [text])
237 ScrollView([.vertical, .horizontal]) { 261 }
238 Text(blob.text ?? "") 262 .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
239 .font(.system(.body, design: .monospaced)) 263 Button("OK", role: .cancel) {}
240 .multilineTextAlignment(.leading) 264 } message: {
241 .fixedSize(horizontal: true, vertical: false) 265 Text(SRHTShareTarget.file.fallbackMessage)
242 .frame(minWidth: geometry.size.width,
243 minHeight: geometry.size.height,
244 alignment: .topLeading)
245 .padding()
246 }
247 .frame(width: geometry.size.width, height: geometry.size.height)
248 }
249 } 266 }
250 } 267 }
251 268
@@ -273,11 +290,6 @@ private struct FileTreeContentView: View {
273 .font(.subheadline) 290 .font(.subheadline)
274 .foregroundStyle(.tertiary) 291 .foregroundStyle(.tertiary)
275 292
276 SRHTShareButton(url: shareURL, target: .file) {
277 Label("Share File", systemImage: "square.and.arrow.up")
278 }
279 .buttonStyle(.bordered)
280
281 if let content = blob.content, let url = URL(string: content) { 293 if let content = blob.content, let url = URL(string: content) {
282 Link(destination: url) { 294 Link(destination: url) {
283 Label("Open in Safari", systemImage: "safari") 295 Label("Open in Safari", systemImage: "safari")
@@ -303,6 +315,460 @@ private struct FileTreeContentView: View {
303 formatter.countStyle = .file 315 formatter.countStyle = .file
304 return formatter.string(fromByteCount: Int64(bytes)) 316 return formatter.string(fromByteCount: Int64(bytes))
305 } 317 }
318
319 private func fileActionToolbar(text: String) -> some View {
320 HStack(spacing: 0) {
321 toolbarButton(
322 title: "Share",
323 systemImage: "square.and.arrow.up"
324 ) {
325 shareFileContents(text)
326 }
327
328 toolbarButton(
329 title: didCopyFileContents ? "Copied" : "Copy All",
330 systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
331 ) {
332 copyFileContents(text)
333 }
334
335 toolbarButton(
336 title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
337 systemImage: "text.word.spacing"
338 ) {
339 wrapRepositoryFileLines.toggle()
340 }
341 }
342 .padding(.horizontal, 8)
343 .padding(.top, 10)
344 .padding(.bottom, 8)
345 .background(.bar)
346 .overlay(alignment: .top) {
347 Divider()
348 }
349 }
350
351 private func toolbarButton(
352 title: String,
353 systemImage: String,
354 action: @escaping () -> Void
355 ) -> some View {
356 Button(action: action) {
357 VStack(spacing: 4) {
358 Image(systemName: systemImage)
359 .font(.system(size: 17, weight: .semibold))
360 Text(title)
361 .font(.caption2)
362 .lineLimit(1)
363 }
364 .frame(maxWidth: .infinity)
365 .contentShape(Rectangle())
366 }
367 .buttonStyle(.plain)
368 .foregroundStyle(.primary)
369 }
370}
371
372private struct CodeFileTextView: UIViewRepresentable {
373 let text: String
374 let fileName: String
375 let wrapLines: Bool
376
377 private let font = UIFont(name: "SFMono-Regular", size: 12)
378 ?? UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
379
380 func makeUIView(context _: Context) -> CodeFileUIView {
381 CodeFileUIView(
382 text: text,
383 fileName: fileName,
384 font: font,
385 wrapLines: wrapLines
386 )
387 }
388
389 func updateUIView(_ uiView: CodeFileUIView, context _: Context) {
390 uiView.updateContent(
391 text: text,
392 fileName: fileName,
393 font: font,
394 wrapLines: wrapLines
395 )
396 }
397}
398
399private struct FileContentShareSheet: UIViewControllerRepresentable {
400 let activityItems: [Any]
401
402 func makeUIViewController(context _: Context) -> UIActivityViewController {
403 UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
404 }
405
406 func updateUIViewController(_ : UIActivityViewController, context _: Context) {}
407}
408
409private final class CodeFileUIView: UIView {
410 private enum Layout {
411 static let verticalPadding: CGFloat = 12
412 static let gutterLeadingPadding: CGFloat = 12
413 static let gutterTrailingPadding: CGFloat = 8
414 }
415
416 private final class LineRow {
417 let gutterRow = UIView()
418 let gutterLabel = UILabel()
419 let codeRow = UIView()
420 let codeLabel = UILabel()
421 let gutterHeightConstraint: NSLayoutConstraint
422 let codeHeightConstraint: NSLayoutConstraint
423
424 init() {
425 gutterRow.translatesAutoresizingMaskIntoConstraints = false
426 gutterLabel.translatesAutoresizingMaskIntoConstraints = false
427 codeRow.translatesAutoresizingMaskIntoConstraints = false
428 codeLabel.translatesAutoresizingMaskIntoConstraints = false
429
430 gutterHeightConstraint = gutterRow.heightAnchor.constraint(equalToConstant: 0)
431 codeHeightConstraint = codeRow.heightAnchor.constraint(equalToConstant: 0)
432 }
433 }
434
435 private let outerScrollView = UIScrollView()
436 private let contentView = UIView()
437 private let gutterContainerView = UIView()
438 private let gutterStackView = UIStackView()
439 private let horizontalScrollView = UIScrollView()
440 private let codeContainerView = UIView()
441 private let codeStackView = UIStackView()
442
443 private var codeContainerWidthConstraint: NSLayoutConstraint?
444 private var gutterWidthConstraint: NSLayoutConstraint?
445
446 private var rows: [LineRow] = []
447 private var currentText = ""
448 private var currentFileName = ""
449 private var currentFont: UIFont
450 private var wrapLines: Bool
451 private var needsLineLayoutUpdate = true
452 private var lastMeasuredCodeWidth: CGFloat = 0
453
454 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
455 self.currentFont = font
456 self.wrapLines = wrapLines
457 super.init(frame: .zero)
458 setupViews()
459 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
460 }
461
462 @available(*, unavailable)
463 required init?(coder: NSCoder) {
464 fatalError("init(coder:) has not been implemented")
465 }
466
467 override func layoutSubviews() {
468 super.layoutSubviews()
469 updateLineLayoutsIfNeeded()
470 }
471
472 func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
473 let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
474 let wrapChanged = wrapLines != self.wrapLines
475
476 currentText = text
477 currentFileName = fileName
478 currentFont = font
479 self.wrapLines = wrapLines
480
481 if contentChanged {
482 rebuildRows()
483 }
484
485 if contentChanged || wrapChanged {
486 updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
487 needsLineLayoutUpdate = true
488 setNeedsLayout()
489 layoutIfNeeded()
490 }
491 }
492
493 private func setupViews() {
494 backgroundColor = .systemBackground
495
496 outerScrollView.translatesAutoresizingMaskIntoConstraints = false
497 contentView.translatesAutoresizingMaskIntoConstraints = false
498 gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
499 gutterStackView.translatesAutoresizingMaskIntoConstraints = false
500 horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
501 codeContainerView.translatesAutoresizingMaskIntoConstraints = false
502 codeStackView.translatesAutoresizingMaskIntoConstraints = false
503
504 gutterStackView.axis = .vertical
505 gutterStackView.alignment = .fill
506 gutterStackView.distribution = .fill
507 gutterStackView.spacing = 0
508
509 codeStackView.axis = .vertical
510 codeStackView.alignment = .fill
511 codeStackView.distribution = .fill
512 codeStackView.spacing = 0
513
514 outerScrollView.alwaysBounceVertical = true
515 horizontalScrollView.alwaysBounceVertical = false
516 horizontalScrollView.showsVerticalScrollIndicator = false
517
518 addSubview(outerScrollView)
519 outerScrollView.addSubview(contentView)
520 contentView.addSubview(gutterContainerView)
521 contentView.addSubview(horizontalScrollView)
522 gutterContainerView.addSubview(gutterStackView)
523 horizontalScrollView.addSubview(codeContainerView)
524 codeContainerView.addSubview(codeStackView)
525
526 codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
527 gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
528
529 NSLayoutConstraint.activate([
530 outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
531 outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
532 outerScrollView.topAnchor.constraint(equalTo: topAnchor),
533 outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
534
535 contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
536 contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
537 contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
538 contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
539 contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
540
541 gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
542 gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
543 gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
544
545 horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
546 horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
547 horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
548 horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
549
550 gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
551 gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
552 gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
553 gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
554
555 codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
556 codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
557 codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
558 codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
559 codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
560
561 codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
562 codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
563 codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
564 codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
565 ])
566
567 gutterWidthConstraint?.isActive = true
568 }
569
570 private func rebuildRows() {
571 rows.forEach { row in
572 gutterStackView.removeArrangedSubview(row.gutterRow)
573 row.gutterRow.removeFromSuperview()
574 codeStackView.removeArrangedSubview(row.codeRow)
575 row.codeRow.removeFromSuperview()
576 }
577 rows.removeAll()
578
579 let attributedText = CodeSyntaxHighlighter.attributedText(
580 for: currentText,
581 fileName: currentFileName,
582 font: currentFont
583 )
584 let lines = makeLines(from: attributedText, font: currentFont)
585
586 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
587
588 for line in lines {
589 let row = makeRow(for: line)
590 rows.append(row)
591 gutterStackView.addArrangedSubview(row.gutterRow)
592 codeStackView.addArrangedSubview(row.codeRow)
593 }
594 }
595
596 private func makeRow(for line: CodeFileLineData) -> LineRow {
597 let row = LineRow()
598
599 row.gutterLabel.font = currentFont
600 row.gutterLabel.textColor = .secondaryLabel
601 row.gutterLabel.textAlignment = .right
602 row.gutterLabel.text = line.number
603
604 row.codeLabel.attributedText = line.text
605 row.codeLabel.font = currentFont
606
607 row.gutterRow.addSubview(row.gutterLabel)
608 row.codeRow.addSubview(row.codeLabel)
609
610 NSLayoutConstraint.activate([
611 row.gutterHeightConstraint,
612 row.codeHeightConstraint,
613
614 row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
615 row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
616 row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
617 row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
618
619 row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
620 row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
621 row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
622 row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
623 ])
624
625 return row
626 }
627
628 private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
629 horizontalScrollView.alwaysBounceHorizontal = !wrapLines
630 horizontalScrollView.isScrollEnabled = true
631
632 if wrapLines {
633 codeContainerWidthConstraint?.isActive = true
634 } else {
635 codeContainerWidthConstraint?.isActive = false
636 }
637
638 for row in rows {
639 row.codeLabel.numberOfLines = wrapLines ? 0 : 1
640 row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
641 }
642
643 if resetHorizontalOffset {
644 horizontalScrollView.setContentOffset(.zero, animated: false)
645 }
646 }
647
648 private func updateLineLayoutsIfNeeded() {
649 let availableWidth = max(horizontalScrollView.bounds.width, 0)
650 let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
651
652 guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
653
654 lastMeasuredCodeWidth = availableWidth
655 let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
656
657 for row in rows {
658 row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
659 let measuredSize = row.codeLabel.sizeThatFits(
660 CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
661 )
662 let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
663 row.gutterHeightConstraint.constant = rowHeight
664 row.codeHeightConstraint.constant = rowHeight
665 }
666
667 needsLineLayoutUpdate = false
668 }
669
670 private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
671 let string = attributedText.string as NSString
672 var lines: [CodeFileLineData] = []
673 var currentLocation = 0
674 var lineNumber = 1
675
676 while currentLocation < attributedText.length {
677 let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
678 let newlineRange = string.range(of: "\n", options: [], range: searchRange)
679 let lineRange: NSRange
680
681 if newlineRange.location == NSNotFound {
682 lineRange = searchRange
683 currentLocation = attributedText.length
684 } else {
685 lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
686 currentLocation = newlineRange.location + newlineRange.length
687 }
688
689 lines.append(CodeFileLineData(
690 number: String(lineNumber),
691 text: attributedText.attributedSubstring(from: lineRange)
692 ))
693 lineNumber += 1
694 }
695
696 if attributedText.length == 0 || string.hasSuffix("\n") {
697 lines.append(CodeFileLineData(
698 number: String(lineNumber),
699 text: NSAttributedString(string: "", attributes: [.font: font])
700 ))
701 }
702
703 return lines
704 }
705
706 private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
707 let digits = String(max(lineCount, 1)).count
708 let sample = String(repeating: "8", count: digits)
709 let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
710 return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
711 }
712}
713
714private struct CodeFileLineData {
715 let number: String
716 let text: NSAttributedString
717}
718
719private enum CodeSyntaxHighlighter {
720 static func attributedText(for text: String, fileName: String, font: UIFont) -> NSAttributedString {
721 guard supportsSplashHighlighting(fileName: fileName) else {
722 return plainText(text, font: font)
723 }
724
725 var splashFont = Font(size: Double(font.pointSize))
726 splashFont.resource = .preloaded(font)
727
728 let theme = Theme(
729 font: splashFont,
730 plainTextColor: .label,
731 tokenColors: [
732 .keyword: .systemPink,
733 .string: .systemRed,
734 .type: .systemTeal,
735 .call: .systemBlue,
736 .number: .systemPurple,
737 .comment: .secondaryLabel,
738 .property: .systemGreen,
739 .dotAccess: .systemIndigo,
740 .preprocessing: .systemOrange
741 ],
742 backgroundColor: .clear
743 )
744
745 let highlighted = SyntaxHighlighter(
746 format: AttributedStringOutputFormat(theme: theme)
747 ).highlight(text)
748
749 return NSMutableAttributedString(attributedString: highlighted)
750 }
751
752 private static func plainText(_ text: String, font: UIFont) -> NSAttributedString {
753 NSAttributedString(
754 string: text,
755 attributes: [
756 .font: font,
757 .foregroundColor: UIColor.label
758 ]
759 )
760 }
761
762 private static func supportsSplashHighlighting(fileName: String) -> Bool {
763 let supportedExtensions: Set<String> = [
764 "swift",
765 "swiftinterface",
766 "playground"
767 ]
768 return supportedExtensions.contains(
769 (fileName as NSString).pathExtension.lowercased()
770 )
771 }
306} 772}
307 773
308// MARK: - Tree Entry Row 774// MARK: - Tree Entry Row
@@ -329,7 +795,7 @@ private struct TreeEntryRow: View {
329 } 795 }
330 } 796 }
331 797
332 private var iconColor: Color { 798 private var iconColor: ViewColor {
333 switch entry.object { 799 switch entry.object {
334 case .tree: .blue 800 case .tree: .blue
335 case .unknown: .orange 801 case .unknown: .orange
@@ -424,7 +890,7 @@ private struct RefPickerSheet: View {
424 } 890 }
425 } 891 }
426 892
427 private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { 893 private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
428 HStack(spacing: 12) { 894 HStack(spacing: 12) {
429 Image(systemName: systemImage) 895 Image(systemName: systemImage)
430 .foregroundStyle(color) 896 .foregroundStyle(color)