| @@ -1,4 +1,8 @@ |
| |
1 | import Splash |
| 1 | import SwiftUI |
2 | import SwiftUI |
| |
3 | import UIKit |
| |
4 | |
| |
5 | private typealias ViewColor = SwiftUI.Color |
| 2 | |
6 | |
| 3 | struct FileTreeView: View { |
7 | struct 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 | |
| 35 | private struct FileTreeContentView: View { |
39 | private 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 | |
| |
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 | // 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) |