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..0e48413 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,61 @@ 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. + /// + /// 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 = + 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 + /// 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) + } +}