Show labels on issue details

This commit is contained in:
Georg Bauer
2026-07-31 18:27:57 +02:00
parent 0a38dbe9f2
commit acf2c839ad
6 changed files with 101 additions and 13 deletions

View File

@@ -183,8 +183,10 @@ xcrun simctl launch booted de.rfc1437.gotcha
issue list/detail remains usable with Dynamic Type and VoiceOver labels. issue list/detail remains usable with Dynamic Type and VoiceOver labels.
- [ ] Open both an open and a closed issue. A green open or purple closed icon - [ ] Open both an open and a closed issue. A green open or purple closed icon
appears beside the title and VoiceOver announces the state; author appears beside the title and VoiceOver announces the state; author
metadata, Markdown body, and comments remain correct. Links and metadata, every colored label, Markdown body, and comments remain correct.
selectable text use normal iOS interaction. Multiple labels wrap without clipping at large Dynamic Type sizes and
VoiceOver announces each label. Links and selectable text use normal iOS
interaction.
- [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a - [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a
list, link, and fenced code block; **Write** syntax-highlights the list, link, and fenced code block; **Write** syntax-highlights the
Markdown, **Preview** renders it, and switching modes preserves the exact Markdown, **Preview** renders it, and switching modes preserves the exact

View File

@@ -77,6 +77,7 @@ pub struct IssuePage {
pub state: String, pub state: String,
pub meta: String, pub meta: String,
pub milestone: String, pub milestone: String,
pub labels: Vec<LabelRow>,
pub body: String, pub body: String,
pub comments: Vec<CommentRow>, pub comments: Vec<CommentRow>,
pub has_more: bool, pub has_more: bool,
@@ -554,6 +555,14 @@ pub fn issue_page(details: IssueDetails) -> IssuePage {
.unwrap_or_else(|| "unknown".into()), .unwrap_or_else(|| "unknown".into()),
meta: issue_meta(&details.issue), meta: issue_meta(&details.issue),
milestone: issue_milestone(&details.issue), milestone: issue_milestone(&details.issue),
labels: details
.issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(label_row)
.collect(),
body: details body: details
.issue .issue
.body .body
@@ -1269,6 +1278,11 @@ mod tests {
let page = issue_page(IssueDetails { let page = issue_page(IssueDetails {
issue: models::Issue { issue: models::Issue {
state: Some("closed".into()), state: Some("closed".into()),
labels: Some(vec![models::Label {
name: Some("bug".into()),
color: Some("ff0000".into()),
..Default::default()
}]),
..Default::default() ..Default::default()
}, },
comments: vec![ comments: vec![
@@ -1297,6 +1311,7 @@ mod tests {
}); });
assert_eq!(page.state, "closed"); assert_eq!(page.state, "closed");
assert_eq!(page.labels[0].name, "bug");
assert_eq!(page.comments[0].id, 7); assert_eq!(page.comments[0].id, 7);
assert!(page.comments[0].can_edit); assert!(page.comments[0].can_edit);
assert!(!page.comments[1].can_edit); assert!(!page.comments[1].can_edit);

View File

@@ -2236,17 +2236,19 @@ public struct IssuePage: Equatable, Hashable {
public var state: String public var state: String
public var meta: String public var meta: String
public var milestone: String public var milestone: String
public var labels: [LabelRow]
public var body: String public var body: String
public var comments: [CommentRow] public var comments: [CommentRow]
public var hasMore: Bool public var hasMore: Bool
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(title: String, state: String, meta: String, milestone: String, body: String, comments: [CommentRow], hasMore: Bool) { public init(title: String, state: String, meta: String, milestone: String, labels: [LabelRow], body: String, comments: [CommentRow], hasMore: Bool) {
self.title = title self.title = title
self.state = state self.state = state
self.meta = meta self.meta = meta
self.milestone = milestone self.milestone = milestone
self.labels = labels
self.body = body self.body = body
self.comments = comments self.comments = comments
self.hasMore = hasMore self.hasMore = hasMore
@@ -2272,6 +2274,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer {
state: FfiConverterString.read(from: &buf), state: FfiConverterString.read(from: &buf),
meta: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf),
milestone: FfiConverterString.read(from: &buf), milestone: FfiConverterString.read(from: &buf),
labels: FfiConverterSequenceTypeLabelRow.read(from: &buf),
body: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf),
comments: FfiConverterSequenceTypeCommentRow.read(from: &buf), comments: FfiConverterSequenceTypeCommentRow.read(from: &buf),
hasMore: FfiConverterBool.read(from: &buf) hasMore: FfiConverterBool.read(from: &buf)
@@ -2283,6 +2286,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer {
FfiConverterString.write(value.state, into: &buf) FfiConverterString.write(value.state, into: &buf)
FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.meta, into: &buf)
FfiConverterString.write(value.milestone, into: &buf) FfiConverterString.write(value.milestone, into: &buf)
FfiConverterSequenceTypeLabelRow.write(value.labels, into: &buf)
FfiConverterString.write(value.body, into: &buf) FfiConverterString.write(value.body, into: &buf)
FfiConverterSequenceTypeCommentRow.write(value.comments, into: &buf) FfiConverterSequenceTypeCommentRow.write(value.comments, into: &buf)
FfiConverterBool.write(value.hasMore, into: &buf) FfiConverterBool.write(value.hasMore, into: &buf)

View File

@@ -370,14 +370,7 @@ final class IssueCell: UITableViewCell {
labels.arrangedSubviews.forEach { $0.removeFromSuperview() } labels.arrangedSubviews.forEach { $0.removeFromSuperview() }
labels.isHidden = row.labels.isEmpty labels.isHidden = row.labels.isEmpty
for label in row.labels.prefix(3) { for label in row.labels.prefix(3) {
let view = UILabel() labels.addArrangedSubview(issueLabelView(label))
view.text = " \(label.name) "
view.font = .preferredFont(forTextStyle: .caption2)
view.textColor = label.light ? .black : .white
view.backgroundColor = UIColor(hex: label.color)
view.layer.cornerRadius = 9
view.clipsToBounds = true
labels.addArrangedSubview(view)
} }
labels.addArrangedSubview(UIView()) labels.addArrangedSubview(UIView())
} }

View File

@@ -232,7 +232,8 @@ final class IssueViewController: MarkdownPageViewController {
body: page.body, body: page.body,
comments: page.comments, comments: page.comments,
editComment: { [weak self] comment in self?.editComment(comment) }, editComment: { [weak self] comment in self?.editComment(comment) },
milestone: page.milestone milestone: page.milestone,
labels: page.labels
)) ))
finishPagination(hasMore: page.hasMore) finishPagination(hasMore: page.hasMore)
} }
@@ -1142,7 +1143,8 @@ private func detailViews(
body: String, body: String,
comments: [CommentRow], comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil, editComment: ((CommentRow) -> Void)? = nil,
milestone: String = "" milestone: String = "",
labels: [LabelRow] = []
) -> [UIView] { ) -> [UIView] {
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = title titleLabel.text = title
@@ -1166,6 +1168,7 @@ private func detailViews(
milestoneLabel.accessibilityLabel = "Milestone \(milestone)" milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
views.append(milestoneLabel) views.append(milestoneLabel)
} }
if !labels.isEmpty { views.append(IssueLabelsView(labels)) }
return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
} }

View File

@@ -287,3 +287,74 @@ func configureIssueStateIcon(
icon.isAccessibilityElement = true icon.isAccessibilityElement = true
icon.accessibilityLabel = accessibilityLabel icon.accessibilityLabel = accessibilityLabel
} }
func issueLabelView(_ label: LabelRow) -> UILabel {
let view = UILabel()
view.text = " \(label.name) "
view.font = .preferredFont(forTextStyle: .caption2)
view.adjustsFontForContentSizeCategory = true
view.textColor = label.light ? .black : .white
view.backgroundColor = UIColor(hex: label.color)
view.layer.cornerRadius = 9
view.clipsToBounds = true
view.accessibilityLabel = "Label \(label.name)"
return view
}
final class IssueLabelsView: UIView {
private let labels: [UILabel]
private var contentHeight: CGFloat
private let spacing: CGFloat = 6
init(_ rows: [LabelRow]) {
labels = rows.map(issueLabelView)
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
super.init(frame: .zero)
labels.forEach(addSubview)
NotificationCenter.default.addObserver(
self,
selector: #selector(contentSizeCategoryDidChange),
name: UIContentSizeCategory.didChangeNotification,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: contentHeight)
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0 else { return }
var origin = CGPoint.zero
var rowHeight: CGFloat = 0
for label in labels {
let size = label.sizeThatFits(
CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
)
if origin.x > 0, origin.x + size.width > bounds.width {
origin.x = 0
origin.y += rowHeight + spacing
rowHeight = 0
}
label.frame = CGRect(origin: origin, size: size)
origin.x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
let height = origin.y + rowHeight
if contentHeight != height {
contentHeight = height
invalidateIntrinsicContentSize()
}
}
@objc private func contentSizeCategoryDidChange() {
labels.forEach { $0.font = .preferredFont(forTextStyle: .caption2) }
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
invalidateIntrinsicContentSize()
setNeedsLayout()
}
}