Wrap list tags onto two lines instead of scrolling
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:
co-authored by
Claude Opus 5
parent
cdbec4aaf8
commit
0b8b9fe512
@@ -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
|
/// Open path: up the left edge, across the top, down the right — no bottom
|
||||||
/// stroke, so the tab merges into the page.
|
/// stroke, so the tab merges into the page.
|
||||||
struct TabOutline: Shape {
|
struct TabOutline: Shape {
|
||||||
|
|||||||
@@ -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 {
|
private var tagRow: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) {
|
||||||
HStack(spacing: 5) {
|
ForEach(effectiveTags, id: \.self) { tag in
|
||||||
ForEach(effectiveTags, id: \.self) { tag in
|
Text(tag)
|
||||||
Text(tag)
|
.font(.system(size: 15, design: .monospaced))
|
||||||
.font(.system(size: 15, design: .monospaced))
|
.foregroundStyle(Paper.ink.opacity(0.55))
|
||||||
.foregroundStyle(Paper.ink.opacity(0.55))
|
.lineLimit(1)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.padding(.top, 1)
|
||||||
// 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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
||||||
|
|||||||
Reference in New Issue
Block a user