diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 9716368..64e2d9e 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -376,6 +376,88 @@ struct LibraryTagStrip: View { } } +// MARK: - Flow layout + +/// Wraps subviews onto as many rows as fit, capped at `maxRows`. Anything past +/// the cap is placed off-screen at zero size rather than skipped — a Layout +/// that declines to place a subview gets it laid out at the origin instead of +/// dropped, which would stack leftover tags on top of the first row. +struct FlowLayout: Layout { + var spacing: CGFloat = 5 + var lineSpacing: CGFloat = 5 + var maxRows: Int = 2 + + struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let rows = rows(width: proposal.width ?? .infinity, subviews: subviews) + let height = rows.reduce(0) { $0 + $1.height } + + CGFloat(max(0, rows.count - 1)) * lineSpacing + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) { + let rows = rows(width: bounds.width, subviews: subviews) + let placed = Set(rows.flatMap(\.indices)) + var y = bounds.minY + + for row in rows { + var x = bounds.minX + for i in row.indices { + let size = subviews[i].sizeThatFits(.unspecified) + subviews[i].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + } + y += row.height + lineSpacing + } + + // Far enough to be off any row, small enough not to poison the layout + // arithmetic the way a near-infinite coordinate would. + for i in subviews.indices where !placed.contains(i) { + subviews[i].place(at: CGPoint(x: bounds.minX - 10_000, y: bounds.minY), + anchor: .topLeading, + proposal: .zero) + } + } + + private func rows(width: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + + for i in subviews.indices { + let size = subviews[i].sizeThatFits(.unspecified) + let needed = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if needed > width && !current.indices.isEmpty { + rows.append(current) + if rows.count == maxRows { return rows } + current = Row() + current.indices = [i] + current.width = size.width + current.height = size.height + } else { + current.indices.append(i) + current.width = needed + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} + /// Open path: up the left edge, across the top, down the right — no bottom /// stroke, so the tab merges into the page. struct TabOutline: Shape { diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift index 81db16e..de0e2fb 100644 --- a/Marks/Views/Library/LibraryListRow.swift +++ b/Marks/Views/Library/LibraryListRow.swift @@ -110,37 +110,25 @@ struct LibraryListRow: View { } } + /// Tags wrap onto up to two lines rather than scrolling sideways. At the + /// scaled-up type a horizontal strip clipped its third chip mid-word, which + /// read as broken text; wrapping shows whole tags or none. private var tagRow: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 5) { - ForEach(effectiveTags, id: \.self) { tag in - Text(tag) - .font(.system(size: 15, design: .monospaced)) - .foregroundStyle(Paper.ink.opacity(0.55)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) - ) - } + FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) { + ForEach(effectiveTags, id: \.self) { tag in + Text(tag) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.55)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) + ) } - .padding(.vertical, 1) } - .scrollBounceBehavior(.basedOnSize) - // Without this the strip clips a tag mid-word at the trailing edge and - // reads as broken text rather than as something you can scroll. - .mask( - LinearGradient( - stops: [ - .init(color: .black, location: 0), - .init(color: .black, location: 0.9), - .init(color: .clear, location: 1), - ], - startPoint: .leading, - endPoint: .trailing - ) - ) + .padding(.top, 1) } /// Excerpt shown under the title: prefer the AI summary (italic), else the