Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
2 changed files with 98 additions and 28 deletions
Showing only changes of commit 0b8b9fe512 - Show all commits
+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 {
+6 -18
View File
@@ -110,13 +110,16 @@ 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) {
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(
@@ -125,22 +128,7 @@ struct LibraryListRow: View {
)
}
}
.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