Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions OpenDocumentReader/DocumentTitleLabel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
65 changes: 65 additions & 0 deletions OpenDocumentReader/DocumentViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -96,6 +100,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
public var document: Document? {
didSet {
document?.delegate = self

if isViewLoaded {
updateDocumentTitle()
}
}
}

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand Down
101 changes: 101 additions & 0 deletions OpenDocumentReaderTests/DocumentTitleTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}