diff --git a/LoopIOS/Feed/CardDetailViewController.swift b/LoopIOS/Feed/CardDetailViewController.swift index 929facd..0880a1f 100644 --- a/LoopIOS/Feed/CardDetailViewController.swift +++ b/LoopIOS/Feed/CardDetailViewController.swift @@ -54,12 +54,16 @@ final class CardDetailViewController: UIViewController { return l }() - private let bodyLabel: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 17, weight: .regular) - l.textColor = UIColor(white: 0.78, alpha: 1) - l.numberOfLines = 0 - return l + /// Vertical stack holding the body content. When the body contains + /// markdown tables the stack receives interleaved text labels and + /// table grid views; otherwise a single label identical to the old + /// `bodyLabel`. + private let bodyStack: UIStackView = { + let s = UIStackView() + s.axis = .vertical + s.spacing = 12 + s.alignment = .fill + return s }() private let divider: UIView = { @@ -128,10 +132,10 @@ final class CardDetailViewController: UIViewController { scrollView.addSubview(contentStack) contentStack.addArrangedSubview(titleLabel) - contentStack.addArrangedSubview(bodyLabel) + contentStack.addArrangedSubview(bodyStack) contentStack.addArrangedSubview(divider) contentStack.addArrangedSubview(metaLabel) - contentStack.setCustomSpacing(22, after: bodyLabel) + contentStack.setCustomSpacing(22, after: bodyStack) contentStack.setCustomSpacing(14, after: divider) NSLayoutConstraint.activate([ @@ -244,17 +248,7 @@ final class CardDetailViewController: UIViewController { .font: UIFont.systemFont(ofSize: 13, weight: .bold)]) titleLabel.text = card.title - - if card.kind == .markdown { - bodyLabel.attributedText = CardMarkdown.attributed( - card.body, - bodyFont: .systemFont(ofSize: 17, weight: .regular), - textColor: UIColor(white: 0.82, alpha: 1), - headingColor: .white, - bulletColor: accent) - } else { - bodyLabel.text = card.body - } + populateBody() var meta = "\(card.kind.rawValue.capitalized) card" if let source = card.source { meta += " · created from \(source)" } @@ -265,6 +259,293 @@ final class CardDetailViewController: UIViewController { metaLabel.text = meta } + // MARK: - Body (table-aware) + + private let cardBodyFont = UIFont.systemFont(ofSize: 17, weight: .regular) + private let cardTextColor = UIColor(white: 0.82, alpha: 1.0) + + /// Parse the card body through `MarkdownSegmenter` and populate + /// `bodyStack` with text labels and/or styled table grids. + private func populateBody() { + bodyStack.arrangedSubviews.forEach { + bodyStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + let body = card.body + guard card.kind == .markdown else { + let label = makeCardBodyLabel() + label.text = body + bodyStack.addArrangedSubview(label) + return + } + + let segments = MarkdownSegmenter.segments(from: body) + for segment in segments { + switch segment { + case .text(let prose): + let label = makeCardBodyLabel() + label.attributedText = CardMarkdown.attributed( + prose, + bodyFont: cardBodyFont, + textColor: cardTextColor, + headingColor: .white, + bulletColor: accent) + bodyStack.addArrangedSubview(label) + + case .table(let table): + bodyStack.addArrangedSubview(makeCardTableView(table: table)) + + case .codeBlock(let block): + bodyStack.addArrangedSubview(makeCardCodeBlockView(block: block)) + } + } + } + + private func makeCardBodyLabel() -> UILabel { + let l = UILabel() + l.font = cardBodyFont + l.textColor = cardTextColor + l.numberOfLines = 0 + return l + } + + /// Styled table grid for the dark card detail sheet. Horizontally + /// scrollable when the table is wider than the available width. + private func makeCardTableView(table: MarkdownTable) -> UIView { + let cellPadH: CGFloat = 10 + let cellPadV: CGFloat = 7 + let minCol: CGFloat = 52 + let maxCol: CGFloat = 200 + let cellFont = UIFont.systemFont(ofSize: 15, weight: .regular) + let headerCellFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + + // Measure column widths + var columnWidths = Array(repeating: minCol, count: table.columnCount) + let allRows = [table.headers] + table.rows + for (rowIdx, row) in allRows.enumerated() { + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerCellFont : cellFont + let size = (cell as NSString).size(withAttributes: [.font: font]) + let needed = ceil(size.width) + cellPadH * 2 + columnWidths[col] = min(maxCol, max(columnWidths[col], needed)) + } + } + let totalTableWidth = columnWidths.reduce(0, +) + + // Wrapper with rounded border + let wrapper = UIView() + wrapper.translatesAutoresizingMaskIntoConstraints = false + wrapper.clipsToBounds = true + wrapper.layer.cornerRadius = 10 + wrapper.layer.cornerCurve = .continuous + wrapper.layer.borderWidth = 0.5 + wrapper.layer.borderColor = UIColor(white: 1, alpha: 0.15).cgColor + wrapper.backgroundColor = UIColor(white: 1, alpha: 0.06) + + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = true + scrollView.showsVerticalScrollIndicator = false + wrapper.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: wrapper.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), + ]) + + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + container.backgroundColor = .clear + scrollView.addSubview(container) + + let fillWidth = container.widthAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.widthAnchor) + fillWidth.priority = UILayoutPriority(999) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + container.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + container.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + container.widthAnchor.constraint(greaterThanOrEqualToConstant: totalTableWidth), + fillWidth, + container.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + + let vstack = UIStackView() + vstack.translatesAutoresizingMaskIntoConstraints = false + vstack.axis = .vertical + vstack.alignment = .fill + vstack.distribution = .fill + vstack.spacing = 0 + container.addSubview(vstack) + NSLayoutConstraint.activate([ + vstack.topAnchor.constraint(equalTo: container.topAnchor), + vstack.bottomAnchor.constraint(equalTo: container.bottomAnchor), + vstack.leadingAnchor.constraint(equalTo: container.leadingAnchor), + vstack.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + + // Header row + vstack.addArrangedSubview( + makeCardTableRow(cells: table.headers, alignments: table.alignments, + columnWidths: columnWidths, isHeader: true, alt: false, + cellFont: cellFont, headerFont: headerCellFont)) + // Data rows + for (i, row) in table.rows.enumerated() { + let div = UIView() + div.translatesAutoresizingMaskIntoConstraints = false + div.backgroundColor = UIColor(white: 1, alpha: 0.08) + div.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + vstack.addArrangedSubview(div) + vstack.addArrangedSubview( + makeCardTableRow(cells: row, alignments: table.alignments, + columnWidths: columnWidths, isHeader: false, + alt: !i.isMultiple(of: 2), + cellFont: cellFont, headerFont: headerCellFont)) + } + + // Height calculation + var totalHeight: CGFloat = 0 + for (rowIdx, row) in allRows.enumerated() { + var maxH: CGFloat = 0 + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerCellFont : cellFont + let w = columnWidths[col] - cellPadH * 2 + let rect = (cell as NSString).boundingRect( + with: CGSize(width: w, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], context: nil) + maxH = max(maxH, ceil(rect.height) + cellPadV * 2) + } + totalHeight += maxH + if rowIdx > 0 { totalHeight += 0.5 } + } + wrapper.heightAnchor.constraint(equalToConstant: totalHeight).isActive = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + scrollView.flashScrollIndicators() + } + return wrapper + } + + private func makeCardTableRow(cells: [String], + alignments: [MarkdownColumnAlignment], + columnWidths: [CGFloat], + isHeader: Bool, + alt: Bool, + cellFont: UIFont, + headerFont: UIFont) -> UIView { + let row = UIView() + row.translatesAutoresizingMaskIntoConstraints = false + if isHeader { + row.backgroundColor = UIColor(white: 1, alpha: 0.08) + } else if alt { + row.backgroundColor = UIColor(white: 1, alpha: 0.03) + } + + let hstack = UIStackView() + hstack.translatesAutoresizingMaskIntoConstraints = false + hstack.axis = .horizontal + hstack.alignment = .fill + hstack.distribution = .fill + hstack.spacing = 0 + row.addSubview(hstack) + NSLayoutConstraint.activate([ + hstack.topAnchor.constraint(equalTo: row.topAnchor), + hstack.bottomAnchor.constraint(equalTo: row.bottomAnchor), + hstack.leadingAnchor.constraint(equalTo: row.leadingAnchor), + hstack.trailingAnchor.constraint(equalTo: row.trailingAnchor), + ]) + + for (i, text) in cells.enumerated() { + let alignment = i < alignments.count ? alignments[i] : .left + let width = i < columnWidths.count ? columnWidths[i] : 70 + + let cell = UIView() + cell.translatesAutoresizingMaskIntoConstraints = false + cell.widthAnchor.constraint(equalToConstant: width).isActive = true + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.font = isHeader ? headerFont : cellFont + label.textColor = isHeader ? .white : cardTextColor + let paragraph = NSMutableParagraphStyle() + switch alignment { + case .left: paragraph.alignment = .left + case .center: paragraph.alignment = .center + case .right: paragraph.alignment = .right + } + paragraph.lineBreakMode = .byWordWrapping + label.attributedText = NSAttributedString( + string: text, + attributes: [.font: label.font!, .foregroundColor: label.textColor!, + .paragraphStyle: paragraph]) + + cell.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: cell.topAnchor, constant: 7), + label.bottomAnchor.constraint(equalTo: cell.bottomAnchor, constant: -7), + label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -10), + ]) + + if i > 0 { + let line = UIView() + line.translatesAutoresizingMaskIntoConstraints = false + line.backgroundColor = UIColor(white: 1, alpha: 0.08) + cell.addSubview(line) + NSLayoutConstraint.activate([ + line.leadingAnchor.constraint(equalTo: cell.leadingAnchor), + line.topAnchor.constraint(equalTo: cell.topAnchor), + line.bottomAnchor.constraint(equalTo: cell.bottomAnchor), + line.widthAnchor.constraint(equalToConstant: 0.5), + ]) + } + hstack.addArrangedSubview(cell) + } + return row + } + + /// Styled code block for the dark card detail sheet. + private func makeCardCodeBlockView(block: MarkdownCodeBlock) -> UIView { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + container.backgroundColor = UIColor(white: 1, alpha: 0.06) + container.layer.cornerRadius = 8 + container.layer.cornerCurve = .continuous + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + label.textColor = UIColor(white: 0.82, alpha: 1) + label.text = block.code + container.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + ]) + + if let lang = block.language, !lang.isEmpty { + let badge = UILabel() + badge.translatesAutoresizingMaskIntoConstraints = false + badge.text = lang + badge.font = UIFont.monospacedSystemFont(ofSize: 10, weight: .medium) + badge.textColor = UIColor(white: 0.5, alpha: 1) + container.addSubview(badge) + NSLayoutConstraint.activate([ + badge.topAnchor.constraint(equalTo: container.topAnchor, constant: 6), + badge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), + ]) + } + return container + } + // MARK: - Actions @objc private func closeTapped() { dismiss(animated: true) } diff --git a/LoopIOS/Feed/MarkdownCardRenderer.swift b/LoopIOS/Feed/MarkdownCardRenderer.swift index 297043f..12a9c33 100644 --- a/LoopIOS/Feed/MarkdownCardRenderer.swift +++ b/LoopIOS/Feed/MarkdownCardRenderer.swift @@ -41,6 +41,7 @@ final class MarkdownCardRenderer: CardRendering { } /// Render a poster-style card with title + body using UIKit drawing. + /// Tables in the body are rendered as styled grids instead of raw pipes. private func renderPoster(title: String, body: String) -> UIImage { let size = CGSize(width: posterWidth, height: posterHeight) let renderer = UIGraphicsImageRenderer(size: size) @@ -77,16 +78,60 @@ final class MarkdownCardRenderer: CardRendering { let titleStr = NSString(string: title) titleStr.draw(with: titleRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], attributes: titleAttrs, context: nil) - // Body — rendered as formatted markdown (headings, bullets, bold) - // rather than literal characters. + // Body — segment-aware: tables get a grid, prose gets text. let bodyFont = UIFont.systemFont(ofSize: 28, weight: .regular) - let bodyTop: CGFloat = margin + 160 - let bodyRect = CGRect(x: margin, y: bodyTop, width: textWidth, height: size.height - bodyTop - margin) - let bodyStr = CardMarkdown.attributed(body, - bodyFont: bodyFont, - textColor: UIColor(white: 0.85, alpha: 1.0), - headingColor: .white) - bodyStr.draw(with: bodyRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil) + let bodyTextColor = UIColor(white: 0.85, alpha: 1.0) + var cursor: CGFloat = margin + 160 + let bodyBottom = size.height - margin + + let segments = MarkdownSegmenter.segments(from: body) + for segment in segments { + guard cursor < bodyBottom else { break } + switch segment { + case .text(let prose): + let availHeight = bodyBottom - cursor + let rect = CGRect(x: margin, y: cursor, width: textWidth, height: availHeight) + let str = CardMarkdown.attributed(prose, + bodyFont: bodyFont, + textColor: bodyTextColor, + headingColor: .white) + str.draw(with: rect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil) + let used = str.boundingRect(with: CGSize(width: textWidth, height: availHeight), + options: [.usesLineFragmentOrigin], + context: nil) + cursor += min(ceil(used.height) + bodyFont.pointSize * 0.6, availHeight) + + case .table(let table): + let drawn = drawPosterTable(table, at: CGPoint(x: margin, y: cursor), + maxWidth: textWidth, maxY: bodyBottom, + ctx: ctx.cgContext) + cursor += drawn + 16 + + case .codeBlock(let block): + let codeFont = UIFont.monospacedSystemFont(ofSize: 22, weight: .regular) + let attrs: [NSAttributedString.Key: Any] = [ + .font: codeFont, + .foregroundColor: UIColor(white: 0.82, alpha: 1), + ] + let availHeight = bodyBottom - cursor + // Tinted background behind the code + let codeStr = NSAttributedString(string: block.code, attributes: attrs) + let codeRect = codeStr.boundingRect( + with: CGSize(width: textWidth - 24, height: availHeight), + options: [.usesLineFragmentOrigin], context: nil) + let bgRect = CGRect(x: margin, y: cursor, + width: textWidth, + height: min(ceil(codeRect.height) + 20, availHeight)) + UIColor(white: 1, alpha: 0.06).setFill() + UIBezierPath(roundedRect: bgRect, cornerRadius: 10).fill() + codeStr.draw(with: CGRect(x: margin + 12, y: cursor + 10, + width: textWidth - 24, + height: bgRect.height - 20), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + context: nil) + cursor += bgRect.height + 12 + } + } // Loop watermark bottom-right let wmFont = UIFont.systemFont(ofSize: 18, weight: .medium) @@ -101,6 +146,148 @@ final class MarkdownCardRenderer: CardRendering { withAttributes: wmAttrs) } } + + // MARK: - Poster table drawing + + /// Draw a styled table grid at `origin` and return the total height used. + private func drawPosterTable(_ table: MarkdownTable, + at origin: CGPoint, + maxWidth: CGFloat, + maxY: CGFloat, + ctx: CGContext) -> CGFloat { + let cellPadH: CGFloat = 14 + let cellPadV: CGFloat = 10 + let cellFont = UIFont.systemFont(ofSize: 22, weight: .regular) + let headerFont = UIFont.systemFont(ofSize: 22, weight: .semibold) + let textColor = UIColor(white: 0.85, alpha: 1) + let headerTextColor = UIColor.white + let gridColor = UIColor(white: 1, alpha: 0.12) + let headerBg = UIColor(white: 1, alpha: 0.10) + let altRowBg = UIColor(white: 1, alpha: 0.04) + let cornerRadius: CGFloat = 10 + + // Measure column widths (proportional to content, capped to maxWidth) + let minCol: CGFloat = 60 + var columnWidths = Array(repeating: minCol, count: table.columnCount) + let allRows = [table.headers] + table.rows + for (rowIdx, row) in allRows.enumerated() { + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerFont : cellFont + let w = (cell as NSString).size(withAttributes: [.font: font]).width + columnWidths[col] = max(columnWidths[col], ceil(w) + cellPadH * 2) + } + } + // Scale columns proportionally if they exceed maxWidth + let rawTotal = columnWidths.reduce(0, +) + if rawTotal > maxWidth { + let scale = maxWidth / rawTotal + columnWidths = columnWidths.map { $0 * scale } + } + let tableWidth = columnWidths.reduce(0, +) + + // Measure row heights + var rowHeights: [CGFloat] = [] + for (rowIdx, row) in allRows.enumerated() { + var maxH: CGFloat = 0 + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerFont : cellFont + let w = columnWidths[col] - cellPadH * 2 + let rect = (cell as NSString).boundingRect( + with: CGSize(width: w, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], context: nil) + maxH = max(maxH, ceil(rect.height) + cellPadV * 2) + } + rowHeights.append(maxH) + } + let totalHeight = rowHeights.reduce(0, +) + + // Clip to available space + let clampedHeight = min(totalHeight, maxY - origin.y) + guard clampedHeight > 0 else { return 0 } + + // Background with rounded corners + let tableRect = CGRect(x: origin.x, y: origin.y, width: tableWidth, height: clampedHeight) + let bgPath = UIBezierPath(roundedRect: tableRect, cornerRadius: cornerRadius) + UIColor(white: 1, alpha: 0.06).setFill() + bgPath.fill() + + // Draw rows + var y = origin.y + for (rowIdx, row) in allRows.enumerated() { + guard y < origin.y + clampedHeight else { break } + let rowH = rowHeights[rowIdx] + + // Row background + if rowIdx == 0 { + ctx.saveGState() + bgPath.addClip() + headerBg.setFill() + UIBezierPath(rect: CGRect(x: origin.x, y: y, width: tableWidth, height: rowH)).fill() + ctx.restoreGState() + } else if !rowIdx.isMultiple(of: 2) { + ctx.saveGState() + bgPath.addClip() + altRowBg.setFill() + UIBezierPath(rect: CGRect(x: origin.x, y: y, width: tableWidth, height: rowH)).fill() + ctx.restoreGState() + } + + // Horizontal divider (skip first row) + if rowIdx > 0 { + gridColor.setStroke() + ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: origin.x + cornerRadius, y: y)) + ctx.addLine(to: CGPoint(x: origin.x + tableWidth - cornerRadius, y: y)) + ctx.strokePath() + } + + // Draw cells + var x = origin.x + for (col, cellText) in row.enumerated() where col < table.columnCount { + let colW = columnWidths[col] + let font = (rowIdx == 0) ? headerFont : cellFont + let color = (rowIdx == 0) ? headerTextColor : textColor + + // Column divider + if col > 0 { + gridColor.setStroke() + ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: x, y: y + 4)) + ctx.addLine(to: CGPoint(x: x, y: y + rowH - 4)) + ctx.strokePath() + } + + let alignment = col < table.alignments.count ? table.alignments[col] : .left + let paragraph = NSMutableParagraphStyle() + switch alignment { + case .left: paragraph.alignment = .left + case .center: paragraph.alignment = .center + case .right: paragraph.alignment = .right + } + paragraph.lineBreakMode = .byTruncatingTail + + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: color, .paragraphStyle: paragraph, + ] + let cellRect = CGRect(x: x + cellPadH, y: y + cellPadV, + width: colW - cellPadH * 2, + height: rowH - cellPadV * 2) + (cellText as NSString).draw(with: cellRect, + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + attributes: attrs, context: nil) + x += colW + } + y += rowH + } + + // Border around the whole table + gridColor.setStroke() + ctx.setLineWidth(1) + bgPath.stroke() + + return min(totalHeight, clampedHeight) + } } #endif diff --git a/LoopIOS/MessagingCell.swift b/LoopIOS/MessagingCell.swift index 9621db7..3952348 100644 --- a/LoopIOS/MessagingCell.swift +++ b/LoopIOS/MessagingCell.swift @@ -1632,24 +1632,83 @@ class MessagingCell: UITableViewCell { return container } - /// Build a UIStackView grid for `table`. Rows are full-width with - /// equal-width columns; header is bold on a tinted background; body - /// rows alternate fill for readability. Borders and dividers use - /// `.separator` so dark mode looks right out of the box. + /// Build a scrollable table grid for `table`. Columns are sized to fit + /// their content (clamped between a min and max width). When the table's + /// natural width exceeds the available message width, a horizontal + /// UIScrollView lets the user pan through. Header is bold on a tinted + /// background; body rows alternate fill for readability. Borders and + /// dividers use `.separator` so dark mode looks right out of the box. private func makeTableView(table: MarkdownTable) -> UIView { - // AdaptiveBorderView re-resolves layer.borderColor on appearance - // changes — UIView.backgroundColor handles that itself for dynamic - // UIColors, but CGColors on CALayer don't, and the border would - // otherwise stay frozen at whatever mode was active when the - // table was first built. - let container = AdaptiveBorderView() + // --- Column width calculation --- + // Measure every cell's single-line text width and pick the widest + // value per column (clamped to [minCol, maxCol]). This gives each + // column just enough room for its content without wasting space. + let cellPadH: CGFloat = 10 // leading + trailing inside each cell + let cellPadV: CGFloat = 8 // top + bottom inside each cell + let minCol: CGFloat = 56 + let maxCol: CGFloat = 220 + let baseFont = UIFont.preferredFont(forTextStyle: .subheadline) + let headerFont = UIFont.systemFont(ofSize: baseFont.pointSize, weight: .semibold) + + var columnWidths = Array(repeating: minCol, count: table.columnCount) + let allRows = [table.headers] + table.rows + for (rowIdx, row) in allRows.enumerated() { + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerFont : baseFont + let size = (cell as NSString).size(withAttributes: [.font: font]) + let needed = ceil(size.width) + cellPadH * 2 + columnWidths[col] = min(maxCol, max(columnWidths[col], needed)) + } + } + let totalTableWidth = columnWidths.reduce(0, +) + + // --- Outer wrapper: rounded card shell that clips the scroll view --- + let wrapper = AdaptiveBorderView() + wrapper.translatesAutoresizingMaskIntoConstraints = false + wrapper.clipsToBounds = true + wrapper.layer.cornerRadius = 10 + wrapper.layer.cornerCurve = .continuous + wrapper.adaptiveBorderColor = UIColor.separator + wrapper.layer.borderWidth = 0.5 + wrapper.backgroundColor = UIColor.secondarySystemBackground + + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = true + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceHorizontal = false + wrapper.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: wrapper.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), + ]) + + // Inner content container — sized to the table's natural width. + let container = UIView() container.translatesAutoresizingMaskIntoConstraints = false - container.layer.cornerRadius = 8 - container.adaptiveBorderColor = UIColor.separator - container.layer.borderWidth = 0.5 - container.layer.masksToBounds = true - container.backgroundColor = UIColor.secondarySystemBackground + container.backgroundColor = .clear + scrollView.addSubview(container) + + // Width: try to fill the frame (for narrow tables) but never shrink + // below the table's measured width (enables scrolling for wide ones). + let fillWidth = container.widthAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.widthAnchor) + fillWidth.priority = UILayoutPriority(999) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + container.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + container.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + container.widthAnchor.constraint(greaterThanOrEqualToConstant: totalTableWidth), + fillWidth, + // Vertical height matches the scroll view frame (no vertical scroll). + container.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + // --- Build rows --- let vstack = UIStackView() vstack.translatesAutoresizingMaskIntoConstraints = false vstack.axis = .vertical @@ -1667,6 +1726,7 @@ class MessagingCell: UITableViewCell { vstack.addArrangedSubview( makeTableRow(cells: table.headers, alignments: table.alignments, + columnWidths: columnWidths, isHeader: true, altBackground: false)) @@ -1675,11 +1735,38 @@ class MessagingCell: UITableViewCell { vstack.addArrangedSubview( makeTableRow(cells: row, alignments: table.alignments, + columnWidths: columnWidths, isHeader: false, - altBackground: i.isMultiple(of: 2) == false)) + altBackground: !i.isMultiple(of: 2))) + } + + // Compute total height by measuring each row's tallest cell, + // accounting for word-wrap inside the column width. + var totalHeight: CGFloat = 0 + for (rowIdx, row) in allRows.enumerated() { + var maxCellHeight: CGFloat = 0 + for (col, cell) in row.enumerated() where col < table.columnCount { + let font = (rowIdx == 0) ? headerFont : baseFont + let availWidth = columnWidths[col] - cellPadH * 2 + let rect = (cell as NSString).boundingRect( + with: CGSize(width: availWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil) + maxCellHeight = max(maxCellHeight, ceil(rect.height) + cellPadV * 2) + } + totalHeight += maxCellHeight + if rowIdx > 0 { totalHeight += 0.5 } // hairline divider } + wrapper.heightAnchor.constraint(equalToConstant: totalHeight).isActive = true - return container + // Flash scroll indicators after a brief delay so the user knows + // the table is horizontally scrollable. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + scrollView.flashScrollIndicators() + } + + return wrapper } private func makeHairlineDivider() -> UIView { @@ -1692,6 +1779,7 @@ class MessagingCell: UITableViewCell { private func makeTableRow(cells: [String], alignments: [MarkdownColumnAlignment], + columnWidths: [CGFloat], isHeader: Bool, altBackground: Bool) -> UIView { let row = UIView() @@ -1708,7 +1796,7 @@ class MessagingCell: UITableViewCell { hstack.translatesAutoresizingMaskIntoConstraints = false hstack.axis = .horizontal hstack.alignment = .fill - hstack.distribution = .fillEqually + hstack.distribution = .fill hstack.spacing = 0 row.addSubview(hstack) NSLayoutConstraint.activate([ @@ -1718,17 +1806,13 @@ class MessagingCell: UITableViewCell { hstack.trailingAnchor.constraint(equalTo: row.trailingAnchor), ]) - // Column dividers go *inside* each non-leading cell rather than - // as arranged subviews of `hstack`. `.fillEqually` requires every - // arranged subview to share width — a 0.5pt divider sitting in - // the line-up either gets stretched (breaking the divider) or - // wins its own width (breaking equal-column sizing), producing - // the squished-column layout we hit before. for (i, cellText) in cells.enumerated() { let alignment = i < alignments.count ? alignments[i] : .left + let width = i < columnWidths.count ? columnWidths[i] : 80 let cellView = makeTableCell(text: cellText, alignment: alignment, isHeader: isHeader, + width: width, leadingDivider: i > 0) hstack.addArrangedSubview(cellView) } @@ -1739,17 +1823,16 @@ class MessagingCell: UITableViewCell { private func makeTableCell(text: String, alignment: MarkdownColumnAlignment, isHeader: Bool, + width: CGFloat, leadingDivider: Bool) -> UIView { let container = UIView() container.translatesAutoresizingMaskIntoConstraints = false + container.widthAnchor.constraint(equalToConstant: width).isActive = true let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping - // Let `.fillEqually` win the width sizing battle. UILabel hugs - // its content by default, which can fight an equal-width row - // when one cell's text is much shorter than the rest. label.setContentHuggingPriority(.defaultLow, for: .horizontal) label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let base = UIFont.preferredFont(forTextStyle: .subheadline) @@ -1757,11 +1840,6 @@ class MessagingCell: UITableViewCell { ? UIFont.systemFont(ofSize: base.pointSize, weight: .semibold) : base label.textColor = .label - // Inline marks (bold/italic/links) inside cells reuse the same - // renderer the surrounding prose uses, so styling stays consistent. - // UILabel ignores `textAlignment` when `attributedText` is set, so - // the alignment is folded into the attributed string via a - // paragraph style applied over the full range. let attributed = NSMutableAttributedString(attributedString: attributedString(from: text)) let paragraph = NSMutableParagraphStyle() switch alignment { @@ -1777,10 +1855,10 @@ class MessagingCell: UITableViewCell { container.addSubview(label) NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: container.topAnchor, constant: 6), - label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -6), - label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), ]) if leadingDivider {