From 4561e734402cbcf9b2ccb7e72cc9de7a228fc8ab Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 6 Sep 2026 17:20:33 +0200 Subject: [PATCH 1/2] Show the document's name in the bar The space between the back button and the rest was empty, and the reader had nothing on screen saying which file was open. The name goes there, without its extension. A long one is truncated in the middle rather than pushing the buttons off the end. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019vF1Rzt2uR8SDMfpN5P59x --- CHANGELOG.md | 4 + OpenDocumentReader/DocumentTitleLabel.swift | 48 +++++++++ .../DocumentViewController.swift | 61 +++++++++++ .../DocumentTitleTests.swift | 101 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 OpenDocumentReader/DocumentTitleLabel.swift create mode 100644 OpenDocumentReaderTests/DocumentTitleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6a416..e7ec6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ once the version tag exists. ## [Unreleased] +### Added + +- The name of the open document is shown at the top, between the buttons. + ### Fixed - An email address or a date in a PDF no longer has a link drawn over it, diff --git a/OpenDocumentReader/DocumentTitleLabel.swift b/OpenDocumentReader/DocumentTitleLabel.swift new file mode 100644 index 0000000..4d86e02 --- /dev/null +++ b/OpenDocumentReader/DocumentTitleLabel.swift @@ -0,0 +1,48 @@ +import UIKit + +/// The document's name as the tool bar shows it. +/// +/// A bar item is as wide as what it holds, so a long name would push the +/// buttons off the end. This one truncates instead. +final class DocumentTitleLabel: UILabel { + + /// The most the name may take. + var maximumWidth: CGFloat = .greatestFiniteMagnitude { + didSet { + guard maximumWidth != oldValue else { return } + + invalidateIntrinsicContentSize() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + // the bar sizes it from its intrinsic width, which is what caps it + translatesAutoresizingMaskIntoConstraints = false + + // the middle of a name says more than its end: "Q3 report (final)" and + // "Q3 report (draft)" differ where a tail truncation cuts + lineBreakMode = .byTruncatingMiddle + textAlignment = .center + font = UIFontMetrics(forTextStyle: .headline).scaledFont( + for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20) + adjustsFontForContentSizeCategory = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + capped(super.intrinsicContentSize) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + capped(super.sizeThatFits(size)) + } + + private func capped(_ size: CGSize) -> CGSize { + CGSize(width: min(size.width, max(0, maximumWidth)), height: size.height) + } +} diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 1314c8f..d8b7843 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -54,6 +54,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel @IBOutlet weak var editButtonSpacer: UIBarButtonItem! @IBOutlet weak var searchButtonSpacer: UIBarButtonItem! + /// The document's name, sitting in the bar's empty middle. + let documentTitleLabel = DocumentTitleLabel() + private lazy var documentTitleItem = UIBarButtonItem(customView: documentTitleLabel) + /// The bar as the storyboard has it, taken before anything is removed, since /// that is the only moment every button is there to be read. private lazy var toolBarItems: [UIBarButtonItem] = toolBar.items ?? [] @@ -96,6 +100,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel public var document: Document? { didSet { document?.delegate = self + + if isViewLoaded { + updateDocumentTitle() + } } } @@ -124,6 +132,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel barButtonItem.accessibilityLabel = NSLocalizedString("back_to_documents", comment: "") updateEditButtonRole() + setUpDocumentTitle() + // nothing is editable or searchable until a page says so updateToolBar() @@ -504,6 +514,57 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } + /// A gap either side of the name, which is what puts it in the middle. + private func setUpDocumentTitle() { + // a glass capsule is what a button looks like, and this is not one + if #available(iOS 26.0, *) { + documentTitleItem.hidesSharedBackground = true + } + + guard let back = toolBarItems.firstIndex(where: { $0 === barButtonItem }) else { return } + + toolBarItems.insert( + contentsOf: [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + documentTitleItem, + ], + at: back + 1) + + updateDocumentTitle() + } + + /// The name without its extension, as the document browser lists it. + private func updateDocumentTitle() { + documentTitleLabel.text = document?.fileURL.deletingPathExtension().lastPathComponent + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateDocumentTitleWidth() + } + + /// What the bar has left once its buttons have taken theirs. + private func updateDocumentTitleWidth() { + let buttons = (toolBar.items ?? []).filter { $0.customView == nil && $0.image != nil } + + documentTitleLabel.maximumWidth = + toolBar.bounds.width - CGFloat(buttons.count) * Self.toolBarButtonWidth - Self.toolBarTitleGap + } + + /// What one button takes of the bar. From iOS 26 a glass capsule with air + /// around it, which is wider than the glyph older bars draw. + private static var toolBarButtonWidth: CGFloat { + if #available(iOS 26.0, *) { + return 64 + } + + return 48 + } + + /// Kept clear either side of the name, so it never sits against a button. + private static let toolBarTitleGap: CGFloat = 16 + private func updateToolBar() { toolBar.items = toolBarItems.filter { item in if item === editButton || item === editButtonSpacer { diff --git a/OpenDocumentReaderTests/DocumentTitleTests.swift b/OpenDocumentReaderTests/DocumentTitleTests.swift new file mode 100644 index 0000000..353d555 --- /dev/null +++ b/OpenDocumentReaderTests/DocumentTitleTests.swift @@ -0,0 +1,101 @@ +import XCTest + +@testable import OpenDocumentReader + +/// The document's name in the tool bar: what it says, and that saying it costs +/// the buttons nothing. +class DocumentTitleTests: XCTestCase { + private var window: UIWindow! + private var controller: DocumentViewController! + + override func tearDown() { + window?.isHidden = true + window = nil + controller = nil + + super.tearDown() + } + + /// The file need not exist: the name is read from the URL, and the bar shows + /// it before the document is opened. + private func present(_ name: String) throws { + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: DocumentViewController.self)) + controller = try XCTUnwrap( + storyboard.instantiateViewController(withIdentifier: "TextDocumentViewController") + as? DocumentViewController) + + let documents = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + controller.document = Document(fileURL: documents.appendingPathComponent(name)) + + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + + controller.view.layoutIfNeeded() + } + + func testTheBarShowsTheNameWithoutTheExtension() throws { + try present("Quarterly report.odt") + + XCTAssertEqual(controller.documentTitleLabel.text, "Quarterly report") + XCTAssertTrue((controller.toolBar.items ?? []).contains { $0.customView === controller.documentTitleLabel }) + } + + /// The name is what a reader recognises the file by, so a dot in it is part + /// of the name and not an extension. + func testOnlyTheLastDotIsTheExtension() throws { + try present("Minutes 12.03.odt") + + XCTAssertEqual(controller.documentTitleLabel.text, "Minutes 12.03") + } + + func testTheNameSitsBetweenTheBackButtonAndTheRest() throws { + try present("Quarterly report.odt") + + let items = try XCTUnwrap(controller.toolBar.items) + let name = try XCTUnwrap(items.firstIndex { $0.customView === controller.documentTitleLabel }) + let back = try XCTUnwrap(items.firstIndex { $0 === controller.barButtonItem }) + let menu = try XCTUnwrap(items.firstIndex { $0 === controller.menuButton }) + + XCTAssertTrue(back < name && name < menu) + } + + /// What used to be the risk: a name long enough to push the buttons off the + /// end of the bar. + func testALongNameIsTruncatedRatherThanWidening() throws { + try present("Quarterly report for the whole board, final revision.odt") + + let label = controller.documentTitleLabel + + XCTAssertLessThanOrEqual(label.bounds.width, label.maximumWidth) + XCTAssertLessThan(label.maximumWidth, label.text!.size(withAttributes: [.font: label.font!]).width) + } + + /// The bar is the same width whoever is in it, so a name has less room when + /// there are more buttons to leave room for. + func testFewerButtonsLeaveTheNameMoreRoom() throws { + try present("Quarterly report.odt") + + let withEverything = controller.documentTitleLabel.maximumWidth + + controller.toolBar.items = (controller.toolBar.items ?? []).filter { $0 !== controller.menuButton } + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + + XCTAssertGreaterThan(controller.documentTitleLabel.maximumWidth, withEverything) + } + + func testTheLabelStopsGrowingAtItsMaximum() { + let label = DocumentTitleLabel() + label.text = String(repeating: "long name ", count: 20) + + label.maximumWidth = .greatestFiniteMagnitude + let unbounded = label.intrinsicContentSize.width + + label.maximumWidth = 120 + + XCTAssertGreaterThan(unbounded, 120) + XCTAssertEqual(label.intrinsicContentSize.width, 120) + } +} From c302ffee24e692a5637075f5f1e0fb70510de45b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 6 Sep 2026 17:50:53 +0200 Subject: [PATCH 2/2] Measure the name against the view, not the bar On the first pass the bar is still the width the storyboard drew it at, so the name was given too much room and kept it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RwqMvK3bAn6jJZ8Uhe1hxs --- OpenDocumentReader/DocumentViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index d8b7843..0e48413 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -545,11 +545,15 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } /// What the bar has left once its buttons have taken theirs. + /// + /// Measured against the view rather than the bar itself: on the first pass + /// the bar still carries the width the storyboard drew it at, and the name + /// keeps whatever width it is first measured at. private func updateDocumentTitleWidth() { let buttons = (toolBar.items ?? []).filter { $0.customView == nil && $0.image != nil } documentTitleLabel.maximumWidth = - toolBar.bounds.width - CGFloat(buttons.count) * Self.toolBarButtonWidth - Self.toolBarTitleGap + view.bounds.width - CGFloat(buttons.count) * Self.toolBarButtonWidth - Self.toolBarTitleGap } /// What one button takes of the bar. From iOS 26 a glass capsule with air