From 5b29e1894294ad84349e298589d30ab03e7abee5 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 17:14:34 +0200 Subject: [PATCH] Show diffs without word wrapping --- TESTING.md | 3 +- ios/Sources/DetailScreens.swift | 155 +++++++++++++++----------------- 2 files changed, 74 insertions(+), 84 deletions(-) diff --git a/TESTING.md b/TESTING.md index 82a7d10..81aebe8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -200,7 +200,8 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Open a commit and verify Changed Files paths and statuses. - [ ] Open a changed file and verify the diff title, old/new line numbers, monospaced text, addition/removal/hunk colors, vertical scrolling, and - horizontal scrolling for long lines. + source-style horizontal scrolling for long lines without word wrapping, + clipped or jumping text, or content hidden beneath the navigation bar. - [ ] Long-press diff text and verify normal selection and copying. ## Repository files diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 6fc62cc..c52f021 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -738,6 +738,60 @@ final class RepositoryHistoryViewController: RefreshingTableViewController { } } +private final class CodeScrollView: UIScrollView { + private let textView = UITextView(frame: .zero) + + override init(frame: CGRect) { + super.init(frame: frame) + translatesAutoresizingMaskIntoConstraints = false + alwaysBounceVertical = true + alwaysBounceHorizontal = true + isDirectionalLockEnabled = true + showsHorizontalScrollIndicator = true + contentInsetAdjustmentBehavior = .never + textView.textContainer.lineBreakMode = .byClipping + textView.textContainer.widthTracksTextView = false + textView.isEditable = false + textView.isScrollEnabled = false + textView.isSelectable = true + addSubview(textView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func display(_ text: NSAttributedString) { + let text = NSMutableAttributedString(attributedString: text) + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byClipping + text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) + textView.textStorage.setAttributedString(text) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + textView.textContainer.size = CGSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.layoutManager.ensureLayout(for: textView.textContainer) + let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size + let size = CGSize( + width: max( + bounds.width, + ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right) + ), + height: max( + bounds.height, + ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom) + ) + ) + textView.frame = CGRect(origin: .zero, size: size) + contentSize = size + } +} + @MainActor final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { private let context: AppContext @@ -753,8 +807,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD private var historyController: RepositoryHistoryViewController? private var displayedController: UIViewController? private var displayedView: UIView? - private weak var sourceScrollView: UIScrollView? - private weak var sourceTextView: UITextView? init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context @@ -791,11 +843,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD } } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - updateSourceLayout() - } - deinit { loadingTask?.cancel() if let previewURL { try? FileManager.default.removeItem(at: previewURL) } @@ -889,41 +936,16 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), .foregroundColor: UIColor.label, ]) - let text = NSMutableAttributedString(attributedString: highlighted) - let paragraph = NSMutableParagraphStyle() - paragraph.lineBreakMode = .byClipping - text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) - - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.alwaysBounceHorizontal = true - scrollView.isDirectionalLockEnabled = true - scrollView.showsHorizontalScrollIndicator = true - scrollView.contentInsetAdjustmentBehavior = .never - - let textView = UITextView(frame: .zero) - textView.textContainer.size = CGSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.textContainer.lineBreakMode = .byClipping - textView.textContainer.widthTracksTextView = false - textView.textStorage.setAttributedString(text) - textView.isEditable = false - textView.isScrollEnabled = false - textView.isSelectable = true - sourceScrollView = scrollView - sourceTextView = textView - scrollView.addSubview(textView) - displayedView = scrollView - view.addSubview(scrollView) + let codeView = CodeScrollView() + codeView.display(highlighted) + displayedView = codeView + view.addSubview(codeView) NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) - updateSourceLayout() } private func removeDisplayedView() { @@ -932,31 +954,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD displayedController?.removeFromParent() displayedController = nil displayedView = nil - sourceScrollView = nil - sourceTextView = nil - } - - private func updateSourceLayout() { - guard let scrollView = sourceScrollView, let textView = sourceTextView else { return } - textView.textContainer.widthTracksTextView = false - textView.textContainer.size = CGSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.layoutManager.ensureLayout(for: textView.textContainer) - let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size - let contentSize = CGSize( - width: max( - scrollView.bounds.width, - ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right) - ), - height: max( - scrollView.bounds.height, - ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom) - ) - ) - textView.frame = CGRect(origin: .zero, size: contentSize) - scrollView.contentSize = contentSize } private func showQuickLook(_ data: Data, name: String) throws { @@ -1002,7 +999,7 @@ enum DiffSource { final class DiffViewController: UIViewController { private let context: AppContext private let source: DiffSource - private let textView = UITextView() + private let codeView = CodeScrollView() private let spinner = UIActivityIndicatorView(style: .medium) private var loadingTask: Task? @@ -1018,22 +1015,14 @@ final class DiffViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground - textView.translatesAutoresizingMaskIntoConstraints = false - textView.isEditable = false - textView.isSelectable = true - textView.alwaysBounceVertical = true - textView.alwaysBounceHorizontal = true - textView.showsHorizontalScrollIndicator = true - textView.textContainer.widthTracksTextView = false - textView.textContainer.lineFragmentPadding = 8 - textView.refreshControl = UIRefreshControl() - textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged) - view.addSubview(textView) + codeView.refreshControl = UIRefreshControl() + codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged) + view.addSubview(codeView) NSLayoutConstraint.activate([ - textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - textView.topAnchor.constraint(equalTo: view.topAnchor), - textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) beginNavigationLoading(spinner) reload() @@ -1063,12 +1052,12 @@ final class DiffViewController: UIViewController { ) } title = page.title - textView.attributedText = diffText(page) + codeView.display(diffText(page)) } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) - textView.refreshControl?.endRefreshing() + codeView.refreshControl?.endRefreshing() } }