| @@ -1,4 +1,8 @@ |
| 1 | import Splash |
| 1 | 2 | import SwiftUI |
| 3 | import UIKit |
| 4 | |
| 5 | private typealias ViewColor = SwiftUI.Color |
| 2 | 6 | |
| 3 | 7 | struct FileTreeView: View { |
| 4 | 8 | let repository: RepositorySummary |
| @@ -9,7 +13,7 @@ struct FileTreeView: View { |
| 9 | 13 | var body: some View { |
| 10 | 14 | Group { |
| 11 | 15 | if let viewModel { |
| 12 | | FileTreeContentView(repository: repository, viewModel: viewModel) |
| 16 | FileTreeContentView(viewModel: viewModel) |
| 13 | 17 | } else { |
| 14 | 18 | SRHTLoadingStateView(message: "Loading files…") |
| 15 | 19 | } |
| @@ -33,10 +37,14 @@ struct FileTreeView: View { |
| 33 | 37 | // MARK: - Content View |
| 34 | 38 | |
| 35 | 39 | private struct FileTreeContentView: View { |
| 36 | | let repository: RepositorySummary |
| 37 | 40 | let viewModel: FileTreeViewModel |
| 38 | 41 | |
| 42 | @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false |
| 39 | 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 | 49 | var body: some View { |
| 42 | 50 | VStack(spacing: 0) { |
| @@ -64,20 +72,11 @@ private struct FileTreeContentView: View { |
| 64 | 72 | get: { viewModel.error }, |
| 65 | 73 | set: { viewModel.error = $0 } |
| 66 | 74 | )) |
| 67 | | .refreshable { |
| 68 | | await viewModel.loadRootTree() |
| 75 | .onChange(of: viewModel.viewingEntry?.name) { _, _ in |
| 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 | 80 | private var revspecLabel: String { |
| 82 | 81 | let revspec = viewModel.revspec |
| 83 | 82 | if revspec == "HEAD" { |
| @@ -138,13 +137,6 @@ private struct FileTreeContentView: View { |
| 138 | 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 | 140 | // MARK: - Content Area |
| 149 | 141 | |
| 150 | 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 | 200 | // MARK: - File Content View |
| 182 | 201 | |
| 183 | 202 | @ViewBuilder |
| @@ -216,36 +235,34 @@ private struct FileTreeContentView: View { |
| 216 | 235 | } |
| 217 | 236 | } |
| 218 | 237 | .listStyle(.plain) |
| 238 | .refreshable { |
| 239 | await viewModel.loadRootTree() |
| 240 | } |
| 219 | 241 | } |
| 220 | 242 | |
| 221 | 243 | // MARK: - Text Blob |
| 222 | 244 | |
| 223 | 245 | @ViewBuilder |
| 224 | 246 | private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View { |
| 247 | let text = blob.text ?? "" |
| 248 | |
| 225 | 249 | VStack(spacing: 0) { |
| 226 | | HStack { |
| 227 | | Spacer() |
| 228 | | SRHTShareButton(url: shareURL, target: .file) { |
| 229 | | Label("Share File", systemImage: "square.and.arrow.up") |
| 230 | | } |
| 231 | | .buttonStyle(.bordered) |
| 232 | | } |
| 233 | | .padding(.horizontal) |
| 234 | | .padding(.top, 12) |
| 235 | | |
| 236 | | GeometryReader { geometry in |
| 237 | | ScrollView([.vertical, .horizontal]) { |
| 238 | | Text(blob.text ?? "") |
| 239 | | .font(.system(.body, design: .monospaced)) |
| 240 | | .multilineTextAlignment(.leading) |
| 241 | | .fixedSize(horizontal: true, vertical: false) |
| 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 | | } |
| 250 | CodeFileTextView( |
| 251 | text: text, |
| 252 | fileName: entry.name, |
| 253 | wrapLines: wrapRepositoryFileLines |
| 254 | ) |
| 255 | } |
| 256 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 257 | fileActionToolbar(text: text) |
| 258 | } |
| 259 | .sheet(isPresented: $showFileShareSheet) { |
| 260 | FileContentShareSheet(activityItems: [text]) |
| 261 | } |
| 262 | .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) { |
| 263 | Button("OK", role: .cancel) {} |
| 264 | } message: { |
| 265 | Text(SRHTShareTarget.file.fallbackMessage) |
| 249 | 266 | } |
| 250 | 267 | } |
| 251 | 268 | |
| @@ -273,11 +290,6 @@ private struct FileTreeContentView: View { |
| 273 | 290 | .font(.subheadline) |
| 274 | 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 | 293 | if let content = blob.content, let url = URL(string: content) { |
| 282 | 294 | Link(destination: url) { |
| 283 | 295 | Label("Open in Safari", systemImage: "safari") |
| @@ -303,6 +315,460 @@ private struct FileTreeContentView: View { |
| 303 | 315 | formatter.countStyle = .file |
| 304 | 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 | |
| 372 | private 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 | |
| 399 | private 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 | |
| 409 | private 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 | |
| 714 | private struct CodeFileLineData { |
| 715 | let number: String |
| 716 | let text: NSAttributedString |
| 717 | } |
| 718 | |
| 719 | private 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 | 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 | 799 | switch entry.object { |
| 334 | 800 | case .tree: .blue |
| 335 | 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 | 894 | HStack(spacing: 12) { |
| 429 | 895 | Image(systemName: systemImage) |
| 430 | 896 | .foregroundStyle(color) |