diff --git a/apple/shared/CoreEvents.swift b/apple/shared/CoreEvents.swift index 0c4709e..8c6f023 100644 --- a/apple/shared/CoreEvents.swift +++ b/apple/shared/CoreEvents.swift @@ -35,6 +35,8 @@ struct Timeline: Decodable, Equatable { struct Row: Decodable, Equatable { let id: String let text: String + /// The post's configured spoken fields with its authored line breaks intact. + var textWithBreaks: String? var favorited = false var boosted = false var hasMedia = false @@ -62,6 +64,7 @@ struct Row: Decodable, Equatable { enum CodingKeys: String, CodingKey { case id, text, favorited, boosted, acct, time, thread, links + case textWithBreaks = "text_with_breaks" case hasMedia = "has_media" case hasPlayableMedia = "has_playable_media" case hasHashtags = "has_hashtags" @@ -81,6 +84,7 @@ struct Row: Decodable, Equatable { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) text = try c.decode(String.self, forKey: .text) + textWithBreaks = try c.decodeIfPresent(String.self, forKey: .textWithBreaks) favorited = try c.decodeIfPresent(Bool.self, forKey: .favorited) ?? false boosted = try c.decodeIfPresent(Bool.self, forKey: .boosted) ?? false hasMedia = try c.decodeIfPresent(Bool.self, forKey: .hasMedia) ?? false diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index 12611f6..691e843 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -5262,6 +5262,9 @@ json CoreSession::row_json(const TimelineItem& item, std::int64_t now) const { json r; r["id"] = item.id(); r["text"] = present::accessibility_label(item, now); + if (const Status* s = std::get_if(&item.value)) + r["text_with_breaks"] = present::accessibility_label( + *s, now, present::SpeechConfig::current().status, /*keep_line_breaks=*/true); if (const Status* s = item.actionable_status()) { r["favorited"] = s->favourited; r["boosted"] = s->boosted; diff --git a/docs/changelog.txt b/docs/changelog.txt index 36184a6..6f004fc 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -4,6 +4,7 @@ FastSMRW changelog 0.6.0 ----- +- Fixed: on iPhone, posts with multiple lines now display with their original line breaks. - New: on iPhone, choose whether the VoiceOver magic tap uses your secondary action, opens compose, or leaves the gesture to iOS; a magic tap set to View Media opens compose when a post has no playable media. - Fixed: on Mastodon, posting no longer fails with an error sound when you have many timelines open; background refreshing now slows down before it uses up your server's request limit. - Changed: threads and user timelines with nothing new for a day now check for updates every 15 minutes instead of every minute, unless you're viewing them, so keeping many open no longer strains your server's request limit. diff --git a/ios/src/DetailScreens.swift b/ios/src/DetailScreens.swift index 836c798..9da5b06 100644 --- a/ios/src/DetailScreens.swift +++ b/ios/src/DetailScreens.swift @@ -43,18 +43,18 @@ class ActionListViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .default, reuseIdentifier: nil) - var content = cell.defaultContentConfiguration() if indexPath.section == 0 { - content.text = body - content.textProperties.numberOfLines = 0 + let cell = PostCell(style: .default, reuseIdentifier: nil) + cell.configure(text: body) cell.selectionStyle = .none - } else { - let item = items[indexPath.row] - content.text = item.title - content.textProperties.color = item.destructive ? .systemRed : .tintColor - cell.accessibilityTraits = .button + return cell } + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + let item = items[indexPath.row] + content.text = item.title + content.textProperties.color = item.destructive ? .systemRed : .tintColor + cell.accessibilityTraits = .button cell.contentConfiguration = content return cell } diff --git a/ios/src/MainViewController.swift b/ios/src/MainViewController.swift index 1313a82..3f5bcbb 100644 --- a/ios/src/MainViewController.swift +++ b/ios/src/MainViewController.swift @@ -476,7 +476,7 @@ final class MainViewController: UIViewController { && oldById[row.id] != row { if let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? PostCell { - cell.configure(text: row.text) + cell.configure(text: row.textWithBreaks ?? row.text) cell.accessibilityCustomActions = accessibilityActions(for: row) heightsChanged = true } @@ -1417,7 +1417,7 @@ extension MainViewController: UITableViewDataSource, UITableViewDelegate { return cell } let row = rows[indexPath.row] - postCell.configure(text: row.text) + postCell.configure(text: row.textWithBreaks ?? row.text) // Resolve the cell's row at focus time — incremental inserts/removes // shift indexes under existing cells, so a captured index goes stale. postCell.onFocused = { [weak self, weak postCell] in @@ -1471,21 +1471,117 @@ extension MainViewController: UITableViewDataSource, UITableViewDelegate { // MARK: - Cell -/// One post row. The whole cell is a single VoiceOver element whose label is -/// the core-composed row text; focusing it moves the reading cursor. -final class PostCell: UITableViewCell { +/// One post row. The cell stays a single VoiceOver element for its actions and +/// reading cursor. ReadingContent exposes authored lines, not visual wraps. +final class PostCell: UITableViewCell, UIAccessibilityReadingContent { static let reuseIdentifier = "PostCell" var onFocused: (() -> Void)? var onUnfocused: (() -> Void)? + private let bodyLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + bodyLabel.numberOfLines = 0 + bodyLabel.lineBreakMode = .byWordWrapping + bodyLabel.font = .preferredFont(forTextStyle: .body) + bodyLabel.textColor = .label + bodyLabel.adjustsFontForContentSizeCategory = true + bodyLabel.isAccessibilityElement = false + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bodyLabel) + NSLayoutConstraint.activate([ + bodyLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + bodyLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + bodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + bodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + ]) + isAccessibilityElement = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(text: String) { - var content = defaultContentConfiguration() - content.text = text - content.textProperties.numberOfLines = 6 - content.textProperties.font = .preferredFont(forTextStyle: .body) - contentConfiguration = content - isAccessibilityElement = true - accessibilityLabel = text + // ReadingContent supplies the spoken page text. A matching accessibility + // label would make VoiceOver announce the entire post a second time. + bodyLabel.text = text.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + private func lines() -> [(text: String, frame: CGRect)] { + guard let text = bodyLabel.text, !text.isEmpty, bodyLabel.bounds.width > 0 else { + return [] + } + let storage = NSTextStorage(string: text, attributes: [.font: bodyLabel.font!]) + let layout = NSLayoutManager() + let container = NSTextContainer(size: CGSize(width: bodyLabel.bounds.width, + height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = .byWordWrapping + storage.addLayoutManager(layout) + layout.addTextContainer(container) + layout.ensureLayout(for: container) + + let authoredLines = text.components(separatedBy: "\n") + let textLength = (text as NSString).length + var result: [(text: String, frame: CGRect)] = [] + var characterOffset = 0 + for line in authoredLines { + let length = (line as NSString).length + // Include the newline glyph for an empty line; the last empty line + // has no glyph, so place it below the preceding line instead. + let glyphLength = length == 0 && characterOffset < textLength + ? 1 : length + var rect = CGRect.zero + if glyphLength > 0 { + let glyphs = layout.glyphRange( + forCharacterRange: NSRange(location: characterOffset, length: glyphLength), + actualCharacterRange: nil) + rect = layout.boundingRect(forGlyphRange: glyphs, in: container) + } + if rect.isEmpty { + let bottom = result.last?.frame.maxY ?? 0 + rect = CGRect(x: 0, y: bottom, width: bodyLabel.bounds.width, + height: bodyLabel.font.lineHeight) + } + rect.origin.x = 0 + rect.size.width = bodyLabel.bounds.width + result.append((line, rect)) + characterOffset += length + 1 + } + return result.map { line in + (line.text, UIAccessibility.convertToScreenCoordinates(line.frame, + in: bodyLabel)) + } + } + + func accessibilityPageContent() -> String? { bodyLabel.text } + + func accessibilityContent(forLineNumber lineNumber: Int) -> String? { + let visibleLines = lines() + return visibleLines.indices.contains(lineNumber) ? visibleLines[lineNumber].text : nil + } + + func accessibilityFrame(forLineNumber lineNumber: Int) -> CGRect { + let visibleLines = lines() + return visibleLines.indices.contains(lineNumber) ? visibleLines[lineNumber].frame : .zero + } + + func accessibilityLineNumber(for point: CGPoint) -> Int { + let visibleLines = lines() + guard !visibleLines.isEmpty else { return NSNotFound } + if let line = visibleLines.indices.first(where: { + visibleLines[$0].frame.minY <= point.y && point.y < visibleLines[$0].frame.maxY + }) { + return line + } + return visibleLines.indices.min { first, second in + let a = visibleLines[first].frame + let b = visibleLines[second].frame + let distanceA = max(a.minY - point.y, point.y - a.maxY, 0) + let distanceB = max(b.minY - point.y, point.y - b.maxY, 0) + return distanceA < distanceB + } ?? NSNotFound } override func accessibilityElementDidBecomeFocused() { diff --git a/tests/test_presentation.cpp b/tests/test_presentation.cpp index c42e362..0d409f4 100644 --- a/tests/test_presentation.cpp +++ b/tests/test_presentation.cpp @@ -416,9 +416,8 @@ void test_presenter_boosted_by_handle() { CHECK_EQ(present::accessibility_label(inner, now, name_only), std::string()); } -// Issue 10: paragraph breaks survived to the view-post dialog but were flattened -// on their way to the clipboard. Speech still gets one line — a screen reader -// row shouldn't sprout newlines. +// Paragraph breaks from both platforms can be exposed to the iOS line rotor and +// to the clipboard, while the default spoken row label remains compact. void test_presenter_copy_keeps_line_breaks() { using present::StatusSpeechField; const std::int64_t now = 1000;