The screen titles were still system bold sans — `navigationTitle` renders through UIKit, which no SwiftUI font modifier reaches, so paperSurface() could tint the bar but never restyle its text. PaperAppearance configures UINavigationBarAppearance once at launch: serif large and inline titles in ink, and monospaced tab bar labels. One trap worth recording: configuring that appearance with configureWithOpaqueBackground() makes iOS 26 stop laying out the large title entirely — it vanishes rather than restyling, and it does so even with the font attributes removed, so it reads like a font problem when it isn't. Transparent works, and is right here anyway: every screen already paints the paper ground itself. Verified in both schemes. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
640 lines
25 KiB
Swift
640 lines
25 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Library design kit
|
||
//
|
||
// The vocabulary the library presentation is built from: a paper palette, the
|
||
// Bookmark -> LibraryItem projection, and the two pieces of chrome (color card,
|
||
// browser-tab filter strip) shared by the real screen and the standalone
|
||
// prototype in Views/Prototypes/LibraryView.swift.
|
||
|
||
// MARK: Tokens
|
||
|
||
enum Paper {
|
||
/// Dark mode is not an inversion of this palette — paper stock lit from a
|
||
/// different angle. The ground keeps the same warm cast (it is brown-black,
|
||
/// not neutral black) so the swatches sit on it the way ink sits on paper.
|
||
static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F)
|
||
static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1)
|
||
static var rule: Color { ink.opacity(0.28) }
|
||
|
||
/// Text weights, named by role so screens stop hand-picking opacities.
|
||
static var secondary: Color { ink.opacity(0.6) }
|
||
static var tertiary: Color { ink.opacity(0.45) }
|
||
static var faint: Color { ink.opacity(0.3) }
|
||
|
||
/// A raised surface on the sheet — cards, fields, banners. Barely separated
|
||
/// from the ground on purpose: this palette does contrast with rules and
|
||
/// type, not with stacked greys.
|
||
static let raised = dynamic(light: 0xFFFDF8, dark: 0x201C16)
|
||
|
||
/// The one non-palette color the design spends, for genuinely interactive
|
||
/// affordances (links, progress, selection). It is the palette's own blue
|
||
/// rather than the system blue, so it belongs to the paper.
|
||
static let accent = dynamic(light: 0x115AB5, dark: 0x5B8FD0)
|
||
|
||
/// Destructive actions still need to read as dangerous; the vermilion
|
||
/// swatch does that without importing systemRed.
|
||
static let alarm = dynamic(light: 0xC03A18, dark: 0xE07A5C)
|
||
|
||
/// "Done / played / complete." The forest swatch, so success still comes
|
||
/// out of the palette rather than from systemGreen.
|
||
static let affirm = dynamic(light: 0x2F6B34, dark: 0x6FA772)
|
||
|
||
/// Swatches lifted from the reference, each paired with a dark-mode
|
||
/// counterpart. Order matters — items hash into it.
|
||
///
|
||
/// The dark variants are not the light ones dimmed uniformly. The pale end
|
||
/// of the palette (shell, blush, pale blue) would glare as bright slabs
|
||
/// against a dark ground, so it drops a long way; the dark end (forest,
|
||
/// navy) would vanish into the ground, so it comes *up*. Both ends
|
||
/// converge on the same mid band, which is what keeps twelve swatches
|
||
/// distinguishable from each other in either scheme.
|
||
static let swatchPairs: [(light: UInt32, dark: UInt32)] = [
|
||
(0xFECD00, 0xD8AD10), // yellow
|
||
(0xED663F, 0xC4552F), // vermilion
|
||
(0xFE9D6B, 0xC87E53), // peach
|
||
(0xAB6A1C, 0x8B5717), // ochre
|
||
(0x033B00, 0x1F4D1B), // forest — lifted off the ground
|
||
(0x001A55, 0x1E3167), // navy — lifted off the ground
|
||
(0x115AB5, 0x1B5596), // blue
|
||
(0xD4E0E8, 0x7C8E99), // pale blue — dropped hard
|
||
(0xD4DCCF, 0x828E7C), // sage — dropped hard
|
||
(0xE0D1BB, 0x94806A), // sand — dropped hard
|
||
(0xF5D1BC, 0xA47A63), // blush — dropped hard
|
||
(0xFDEDE0, 0x8E8175), // shell — dropped hard
|
||
]
|
||
|
||
static func swatch(_ index: Int) -> Color {
|
||
let pair = swatchPairs[index % swatchPairs.count]
|
||
return dynamic(light: pair.light, dark: pair.dark)
|
||
}
|
||
|
||
/// Text that stays legible on `swatch(index)`. Resolved per scheme rather
|
||
/// than once, because a swatch can be light in one scheme and mid in the
|
||
/// other — a single luminance test would get one of them wrong.
|
||
static func inkOn(_ index: Int) -> Color {
|
||
let pair = swatchPairs[index % swatchPairs.count]
|
||
return Color(uiColor: UIColor { traits in
|
||
let dark = traits.userInterfaceStyle == .dark
|
||
let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55
|
||
let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF)
|
||
return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1)
|
||
})
|
||
}
|
||
|
||
static func dynamic(light: UInt32, dark: UInt32) -> Color {
|
||
Color(uiColor: dynamicUI(light: light, dark: dark))
|
||
}
|
||
|
||
static func dynamicUI(light: UInt32, dark: UInt32) -> UIColor {
|
||
UIColor { traits in
|
||
UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light)
|
||
}
|
||
}
|
||
|
||
/// UIKit equivalents, for the chrome SwiftUI can't reach — chiefly the
|
||
/// navigation bar, whose title font has no SwiftUI API at all.
|
||
static let uiSheet = dynamicUI(light: 0xF8F5EF, dark: 0x15130F)
|
||
static let uiInk = dynamicUI(light: 0x14110C, dark: 0xF1ECE1)
|
||
|
||
static func luminance(of hex: UInt32) -> Double {
|
||
let r = Double((hex >> 16) & 0xFF) / 255
|
||
let g = Double((hex >> 8) & 0xFF) / 255
|
||
let b = Double(hex & 0xFF) / 255
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||
}
|
||
}
|
||
|
||
// MARK: Type
|
||
|
||
/// The library's two voices. Prose — titles, summaries, anything a person
|
||
/// wrote — is serif. Everything the machine contributes — domains, dates,
|
||
/// counts, tags, labels, buttons — is monospaced. Keeping the split strict is
|
||
/// what makes the design read as archival rather than as decoration.
|
||
enum PaperType {
|
||
/// Screen titles that aren't the navigation bar's.
|
||
static let display = Font.system(size: 28, design: .serif)
|
||
static let title = Font.system(size: 21, design: .serif)
|
||
static let heading = Font.system(size: 18, weight: .medium, design: .serif)
|
||
static let body = Font.system(size: 18, design: .serif)
|
||
static let quote = Font.system(size: 16.5, design: .serif)
|
||
|
||
/// Machine voice.
|
||
static let label = Font.system(size: 16.5, design: .monospaced)
|
||
static let meta = Font.system(size: 15, design: .monospaced)
|
||
static let micro = Font.system(size: 12, design: .monospaced)
|
||
/// Section headers and anything that wants to read as a stamp.
|
||
static let stamp = Font.system(size: 12, weight: .medium, design: .monospaced)
|
||
}
|
||
|
||
// MARK: Surfaces
|
||
|
||
extension View {
|
||
/// Puts a screen on the paper ground: clears the system list/scroll
|
||
/// background so the sheet shows through, and tints the bar to match so a
|
||
/// large title doesn't sit on a white strip above the content.
|
||
func paperSurface() -> some View {
|
||
self
|
||
.scrollContentBackground(.hidden)
|
||
.background(Paper.sheet.ignoresSafeArea())
|
||
.toolbarBackground(Paper.sheet, for: .navigationBar)
|
||
}
|
||
|
||
/// Editable text. Monospaced, because in this design anything you type is
|
||
/// data rather than prose — which is also what the reference's form screen
|
||
/// did with every field.
|
||
func paperField() -> some View {
|
||
self
|
||
.font(PaperType.label)
|
||
.foregroundStyle(Paper.ink)
|
||
.tint(Paper.accent)
|
||
}
|
||
|
||
/// A raised block — settings groups, banners, editor fields.
|
||
func paperCard(cornerRadius: CGFloat = 8) -> some View {
|
||
self
|
||
.background(
|
||
RoundedRectangle(cornerRadius: cornerRadius).fill(Paper.raised)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - UIKit chrome
|
||
|
||
/// The navigation and tab bars are UIKit underneath, and their fonts have no
|
||
/// SwiftUI equivalent — `navigationTitle` will render in the system bold sans
|
||
/// whatever you do to the view. Both are configured once at launch so screen
|
||
/// titles speak the same serif as the content beneath them.
|
||
enum PaperAppearance {
|
||
static func apply() {
|
||
let bar = UINavigationBarAppearance()
|
||
// Transparent, not opaque: the screens already paint the paper ground
|
||
// themselves, and an opaque bar config stops iOS 26 laying out the
|
||
// large title at all.
|
||
bar.configureWithTransparentBackground()
|
||
bar.largeTitleTextAttributes = [
|
||
.font: serif(34, weight: .regular),
|
||
.foregroundColor: Paper.uiInk,
|
||
]
|
||
bar.titleTextAttributes = [
|
||
.font: serif(17, weight: .medium),
|
||
.foregroundColor: Paper.uiInk,
|
||
]
|
||
UINavigationBar.appearance().standardAppearance = bar
|
||
UINavigationBar.appearance().compactAppearance = bar
|
||
UINavigationBar.appearance().scrollEdgeAppearance = bar
|
||
|
||
let tabs = UITabBarAppearance()
|
||
tabs.configureWithDefaultBackground()
|
||
let label: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium)
|
||
]
|
||
for item in [tabs.stackedLayoutAppearance,
|
||
tabs.inlineLayoutAppearance,
|
||
tabs.compactInlineLayoutAppearance] {
|
||
item.normal.titleTextAttributes = label
|
||
item.selected.titleTextAttributes = label
|
||
}
|
||
UITabBar.appearance().standardAppearance = tabs
|
||
UITabBar.appearance().scrollEdgeAppearance = tabs
|
||
}
|
||
|
||
/// `withDesign(.serif)` is the only route to the system serif in UIKit, and
|
||
/// it returns nil if the design is unavailable — fall back rather than
|
||
/// force-unwrap a font.
|
||
private static func serif(_ size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||
let base = UIFont.systemFont(ofSize: size, weight: weight)
|
||
guard let descriptor = base.fontDescriptor.withDesign(.serif) else { return base }
|
||
return UIFont(descriptor: descriptor, size: size)
|
||
}
|
||
}
|
||
|
||
// MARK: - Empty state
|
||
|
||
/// The paper equivalent of `ContentUnavailableView`, which can't be restyled —
|
||
/// it draws its own bold system type and grey, which was the loudest remaining
|
||
/// system voice once every screen moved onto the sheet.
|
||
struct PaperEmptyState: View {
|
||
let title: String
|
||
let systemImage: String
|
||
var message: String?
|
||
|
||
var body: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 34, weight: .ultraLight))
|
||
.foregroundStyle(Paper.faint)
|
||
Text(title)
|
||
.font(PaperType.title)
|
||
.foregroundStyle(Paper.secondary)
|
||
if let message {
|
||
Text(message)
|
||
.font(PaperType.meta)
|
||
.foregroundStyle(Paper.tertiary)
|
||
.multilineTextAlignment(.center)
|
||
.frame(maxWidth: 280)
|
||
}
|
||
}
|
||
.padding(32)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
extension UIColor {
|
||
convenience init(rgb: UInt32) {
|
||
self.init(
|
||
red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||
blue: CGFloat(rgb & 0xFF) / 255,
|
||
alpha: 1
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: Model
|
||
|
||
struct LibraryItem: Identifiable, Hashable {
|
||
let id: Int
|
||
var title: String
|
||
/// The bottom-left mono stamp. The reference used a publication year; real
|
||
/// linkding data is all 2025–2026, so the year carries no signal and the
|
||
/// domain takes the slot instead.
|
||
var stamp: String
|
||
var source: String
|
||
var tags: [String]
|
||
/// nil = derive from the primary tag, so an untouched library still reads
|
||
/// as color-coded by subject rather than as noise.
|
||
var colorIndex: Int?
|
||
/// The page never gave us a title. Rendering the raw URL inside curly
|
||
/// quotes reads as a quotation that isn't one, so these skip the quotes.
|
||
var isUntitled = false
|
||
|
||
var swatch: Color { Paper.swatch(resolvedIndex) }
|
||
var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) }
|
||
|
||
/// What a card shows. Cards get one clause; the list gets the whole title.
|
||
/// Real titles are overwhelmingly "name: what it does" — at card width the
|
||
/// name is the identifier and the blurb is filler, so past 60 characters we
|
||
/// keep the name and let the list carry the rest.
|
||
var cardTitle: String {
|
||
if title.count > 60,
|
||
let colon = title.range(of: ": "),
|
||
title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 {
|
||
return String(title[..<colon.lowerBound])
|
||
}
|
||
if title.count > 56 {
|
||
return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…"
|
||
}
|
||
return title
|
||
}
|
||
|
||
/// Cards quote the title the way the reference does — except when there is
|
||
/// no real title to quote.
|
||
var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" }
|
||
var listDisplay: String { isUntitled ? title : "“\(title)”" }
|
||
|
||
private var resolvedIndex: Int {
|
||
if let colorIndex { return colorIndex % Paper.swatchPairs.count }
|
||
// Tag first: 128 of 300 real bookmarks are github.com, so hashing the
|
||
// domain would paint half the library one color. Tags spread wider
|
||
// (top tag is 32 items). Untagged falls back to source.
|
||
let seed = tags.first ?? source
|
||
return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) })
|
||
% Paper.swatchPairs.count
|
||
}
|
||
}
|
||
|
||
// MARK: Bookmark -> LibraryItem
|
||
|
||
extension LibraryItem {
|
||
init(bookmark: Bookmark) {
|
||
self.id = bookmark.id
|
||
self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "")
|
||
self.tags = bookmark.tagNames
|
||
self.colorIndex = nil
|
||
|
||
// `displayTitle` falls back to the raw URL when linkding scraped no
|
||
// title — 15 of 300 real bookmarks. Show the domain instead and hand
|
||
// the stamp slot the path, so both slots still say something.
|
||
let raw = bookmark.displayTitle
|
||
if raw.hasPrefix("http") {
|
||
self.isUntitled = true
|
||
self.title = self.source
|
||
self.stamp = Self.path(of: bookmark.url)
|
||
} else {
|
||
self.title = Self.clean(raw)
|
||
self.stamp = Self.registered(self.source)
|
||
}
|
||
}
|
||
|
||
/// Cards give the stamp one monospaced line, roughly 14 characters at the
|
||
/// card's width. 92 of 300 real domains are longer than that, so drop the
|
||
/// subdomain: `toolkit.artlist.io` -> `artlist.io`. The list shows it full.
|
||
static func registered(_ host: String) -> String {
|
||
let parts = host.split(separator: ".")
|
||
guard parts.count > 2 else { return host }
|
||
// Two-part public suffixes (.co.uk, .com.au) need one more label.
|
||
let secondLevel: Set<String> = ["co", "com", "net", "org", "ac", "gov", "edu"]
|
||
let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2
|
||
return parts.suffix(keep).joined(separator: ".")
|
||
}
|
||
|
||
/// Returned whole: the stamp label middle-truncates, and pre-clipping here
|
||
/// too would elide it twice ("/share…fo/194…").
|
||
static func path(of url: String) -> String {
|
||
guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" }
|
||
return p
|
||
}
|
||
|
||
/// Linkding stores whatever the page's <title> said, which for the bulk of a
|
||
/// real library means "GitHub - owner/repo: <the entire README blurb>".
|
||
/// Median real title is 66 chars and the 90th percentile is 159 — the
|
||
/// reference design assumed ~30. Strip the boilerplate, then clamp.
|
||
static func clean(_ raw: String) -> String {
|
||
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// "GitHub - owner/repo: blurb" -> "repo: blurb"
|
||
if t.hasPrefix("GitHub - ") {
|
||
t = String(t.dropFirst("GitHub - ".count))
|
||
if let slash = t.firstIndex(of: "/"),
|
||
let colon = t.firstIndex(of: ":"), slash < colon {
|
||
t = String(t[t.index(after: slash)...])
|
||
}
|
||
}
|
||
|
||
// Trailing site furniture: "Title | Publisher", "Title - Latent.Space".
|
||
// Only strip a short trailing fragment off a title with something left
|
||
// over, so hyphenated titles survive.
|
||
for sep in [" | ", " · ", " — ", " – ", " - "] {
|
||
if let r = t.range(of: sep, options: .backwards),
|
||
t.distance(from: r.upperBound, to: t.endIndex) < 24,
|
||
t.distance(from: t.startIndex, to: r.lowerBound) > 12 {
|
||
t = String(t[..<r.lowerBound])
|
||
}
|
||
}
|
||
|
||
// Generous clamp: this is the list-mode title. `cardTitle` cuts harder.
|
||
if t.count > 120 {
|
||
t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…"
|
||
}
|
||
return t.isEmpty ? "Untitled" : t
|
||
}
|
||
}
|
||
|
||
// MARK: Filters
|
||
|
||
struct LibraryFilter: Identifiable, Hashable {
|
||
let id = UUID()
|
||
var name: String
|
||
/// nil = "everything", the tab you can't close.
|
||
var tag: String?
|
||
}
|
||
|
||
/// Two modes, not three. A 4-column grid was in the first pass and died on real
|
||
/// data: with a median title of 66 characters every tile truncated mid-word, so
|
||
/// it read as a wall of clipped text rather than as color.
|
||
enum LibraryLayout: String, CaseIterable, Identifiable {
|
||
case cards, list
|
||
var id: String { rawValue }
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
case .cards: "rectangle.inset.filled"
|
||
case .list: "line.3.horizontal"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Card
|
||
|
||
/// Purely presentational. Press feedback is deliberately *not* here — a
|
||
/// zero-duration long-press gesture on the card swallows any tap the host
|
||
/// attaches, which silently broke tap-to-open. Hosts wrap this in a Button
|
||
/// with `RowPressStyle` instead, which gets the same scale without competing
|
||
/// for the gesture.
|
||
struct LibraryCard: View {
|
||
let item: LibraryItem
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(item.cardDisplay)
|
||
.font(.system(size: 16.5, design: .serif))
|
||
.foregroundStyle(item.inkOnSwatch)
|
||
.multilineTextAlignment(.leading)
|
||
.lineLimit(5)
|
||
.minimumScaleFactor(0.8)
|
||
Spacer(minLength: 4)
|
||
Text(item.stamp)
|
||
.font(.system(size: 16.5, design: .monospaced))
|
||
.foregroundStyle(item.inkOnSwatch)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
.padding(13)
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.aspectRatio(0.82, contentMode: .fit)
|
||
.background(RoundedRectangle(cornerRadius: 5).fill(item.swatch))
|
||
}
|
||
}
|
||
|
||
// MARK: - Filter strip
|
||
|
||
/// The browser-tab strip of saved filters. Tabs sit on a hairline that the live
|
||
/// tab erases, which is what sells the metaphor.
|
||
struct LibraryTagStrip: View {
|
||
@Binding var filters: [LibraryFilter]
|
||
@Binding var selection: LibraryFilter.ID?
|
||
var onChange: () -> Void = {}
|
||
/// Opens the tag picker. It replaced an inline Menu, which could only list
|
||
/// tags found on the loaded page — and once a tag filter was active, that
|
||
/// collapsed to the handful of tags co-occurring with it.
|
||
var onBrowseTags: () -> Void = {}
|
||
|
||
@Namespace private var strip
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottom) {
|
||
Rectangle()
|
||
.fill(Paper.rule)
|
||
.frame(height: 0.6)
|
||
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 0) {
|
||
ForEach(filters) { filter in
|
||
tab(filter)
|
||
}
|
||
Button { onBrowseTags() } label: {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 18, weight: .light))
|
||
.foregroundStyle(Paper.ink.opacity(0.6))
|
||
.frame(width: 50, height: 42)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Pin a tag")
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, 18)
|
||
}
|
||
}
|
||
.frame(height: 42)
|
||
}
|
||
|
||
private func tab(_ filter: LibraryFilter) -> some View {
|
||
let active = filter.id == selection
|
||
return HStack(spacing: 7) {
|
||
Text(filter.name)
|
||
.font(.system(size: 16.5, design: .monospaced))
|
||
.foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45))
|
||
.lineLimit(1)
|
||
if filters.count > 1 {
|
||
Button {
|
||
withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) }
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 15)
|
||
.frame(height: 42)
|
||
.background(alignment: .bottom) {
|
||
if active {
|
||
// Paper fill sits 0.6pt proud so it erases the strip rule
|
||
// beneath the live tab — the browser-tab read.
|
||
UnevenRoundedRectangle(
|
||
topLeadingRadius: 6, bottomLeadingRadius: 0,
|
||
bottomTrailingRadius: 0, topTrailingRadius: 6
|
||
)
|
||
.fill(Paper.sheet)
|
||
.overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6))
|
||
.padding(.bottom, -0.6)
|
||
.matchedGeometryEffect(id: "tab", in: strip)
|
||
}
|
||
}
|
||
.contentShape(.rect)
|
||
.onTapGesture {
|
||
guard filter.id != selection else { return }
|
||
withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selection = filter.id }
|
||
onChange()
|
||
}
|
||
}
|
||
|
||
private func close(_ filter: LibraryFilter) {
|
||
filters.removeAll { $0.id == filter.id }
|
||
if selection == filter.id {
|
||
selection = filters.first?.id
|
||
onChange()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
func path(in rect: CGRect) -> Path {
|
||
let r: CGFloat = 6
|
||
var p = Path()
|
||
p.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r))
|
||
p.addQuadCurve(
|
||
to: CGPoint(x: rect.minX + r, y: rect.minY),
|
||
control: CGPoint(x: rect.minX, y: rect.minY)
|
||
)
|
||
p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY))
|
||
p.addQuadCurve(
|
||
to: CGPoint(x: rect.maxX, y: rect.minY + r),
|
||
control: CGPoint(x: rect.maxX, y: rect.minY)
|
||
)
|
||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||
return p
|
||
}
|
||
}
|