Only the bookmarks screen had picked up the serif title. Every other screen — Search, Sources, Podcasts, Settings, Ask, the sheets — was still system bold sans, and the reason was paperSurface(): setting .toolbarBackground makes SwiftUI build a fresh UINavigationBarAppearance and discard the one PaperAppearance installed, text attributes included. Bookmarks was the only screen not using the helper, which is why it alone looked right. The modifier no longer sets a toolbar background. It doesn't need one — the bar is transparent by appearance and the screen already paints the paper ground beneath it. Also labels the bookmarks toolbar buttons (Settings, Add bookmark, Unread filter, AI actions), which were bare SF Symbols announcing nothing to VoiceOver. Verified in both schemes: large titles on Bookmarks/Sources/Podcasts/ Search and inline titles on the Settings and Ask sheets all render serif, on paper grounds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
645 lines
25 KiB
Swift
645 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 lets the paper reach past the
|
||
/// safe area so the bar sits on it.
|
||
///
|
||
/// Deliberately no `.toolbarBackground` — setting it makes SwiftUI build a
|
||
/// fresh UINavigationBarAppearance and throw away the one PaperAppearance
|
||
/// installed, which silently reverted these screens' titles to the system
|
||
/// bold sans. The bar is transparent by appearance, so the background
|
||
/// below is all it needs.
|
||
func paperSurface() -> some View {
|
||
self
|
||
.scrollContentBackground(.hidden)
|
||
.background(Paper.sheet.ignoresSafeArea())
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|