Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
581 lines
23 KiB
Swift
581 lines
23 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: UIColor { traits in
|
||
UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light)
|
||
})
|
||
}
|
||
|
||
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: - 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
|
||
}
|
||
}
|