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 apple/shared/CoreEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions core/src/session/core_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Status>(&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;
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 9 additions & 9 deletions ios/src/DetailScreens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
120 changes: 108 additions & 12 deletions ios/src/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 2 additions & 3 deletions tests/test_presentation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading