Wrap list tags onto two lines instead of scrolling
CI / build-and-deploy (push) Successful in 20s
CI / build-and-deploy (pull_request) Successful in 20s

At the scaled-up type a horizontal tag strip clipped its third chip
mid-word, and a fade only made the clipping prettier. Tags now wrap: you
see whole tags or none.

SwiftUI has no wrapping stack, so this adds a small FlowLayout capped at
maxRows. Subviews past the cap are placed off-screen at zero size rather
than left unplaced — a Layout that declines to place a subview gets it
laid out at the origin instead of dropped, which would have stacked the
leftover tags on top of the first row. Verified by temporarily forcing
maxRows to 1: the overflow disappears cleanly, with no ghost chips.

Worth knowing: the cap does discard tags on real data. The heaviest
bookmarks carry five linkding tags plus AI tags, and two rows hold about
four chips at this size, so the tail is hidden rather than truncated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
This commit is contained in:
Krishna Kumar
2026-07-26 01:59:19 -05:00
co-authored by Claude Opus 5
parent cdbec4aaf8
commit 0b8b9fe512
2 changed files with 98 additions and 28 deletions
+82
View File
@@ -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 {