Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 64 additions & 52 deletions Sources/Pesty/UI/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import SwiftUI

struct BarView: View {
private static let stripStartID = "pesty.clip-strip.start"
private static let stripTopInset: CGFloat = 16
private static let stripBottomInset: CGFloat = 26

@Bindable private var store = ClipboardStore.shared
@Bindable private var settings = Settings.shared
Expand Down Expand Up @@ -186,68 +188,78 @@ struct BarView: View {
}

private var strip: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: Theme.cardSpacing) {
// This is a real scroll target, rather than an ID applied
// to the HStack. Scrolling it to the leading edge leaves
// one card-spacing of room before the first card.
Color.clear
.frame(width: 1, height: 1)
.id(Self.stripStartID)
GeometryReader { geometry in
// A horizontal ScrollView measures its content at intrinsic height.
// Link cards now include a richer preview, so without an explicit
// height those cards make the LazyHStack center shorter cards around
// them. Size every card from the available strip height instead.
let cardHeight = max(1, geometry.size.height - Self.stripTopInset - Self.stripBottomInset)

if showsStackDeck {
ForEach(sequence.savedStacks.filter(\.hasEntries)) { stack in
PasteStackDeckCard(stack: stack,
isActive: stack.id == sequence.activeStackID,
isCollecting: stack.id == sequence.activeStackID && sequence.isCollecting)
.id(stack.id)
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: Theme.cardSpacing) {
// This is a real scroll target, rather than an ID applied
// to the HStack. Scrolling it to the leading edge leaves
// one card-spacing of room before the first card.
Color.clear
.frame(width: 1, height: 1)
.id(Self.stripStartID)

if showsStackDeck {
ForEach(sequence.savedStacks.filter(\.hasEntries)) { stack in
PasteStackDeckCard(stack: stack,
isActive: stack.id == sequence.activeStackID,
isCollecting: stack.id == sequence.activeStackID && sequence.isCollecting)
.frame(height: cardHeight)
.id(stack.id)
}
}
}

ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in
ClipCardView(item: item,
index: index,
selected: item.id == store.selectedID)
.id(item.id)
.background {
GeometryReader { proxy in
Color.clear.preference(
key: ClipCardFramePreferenceKey.self,
value: [item.id: proxy.frame(in: .named("PestyBar"))])
ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in
ClipCardView(item: item,
index: index,
selected: item.id == store.selectedID)
.frame(height: cardHeight)
.id(item.id)
.background {
GeometryReader { proxy in
Color.clear.preference(
key: ClipCardFramePreferenceKey.self,
value: [item.id: proxy.frame(in: .named("PestyBar"))])
}
}
}
.transition(.asymmetric(
insertion: .scale(scale: 0.92).combined(with: .opacity),
removal: .opacity))
.transition(.asymmetric(
insertion: .scale(scale: 0.92).combined(with: .opacity),
removal: .opacity))
}
}
.padding(.trailing, 28)
.padding(.top, Self.stripTopInset)
.padding(.bottom, Self.stripBottomInset)
.animation(.spring(response: 0.34, dampingFraction: 0.8), value: store.visibleItems.count)
}
.padding(.trailing, 28)
.padding(.top, 16)
.padding(.bottom, 26)
.animation(.spring(response: 0.34, dampingFraction: 0.8), value: store.visibleItems.count)
}
.scrollClipDisabled()
.onChange(of: store.selectedID) { _, id in
guard let id else { return }
if store.initialScrollTargetID == id {
store.initialScrollTargetID = nil
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
// The leading spacer preserves room for the first
// card's focus ring when reopening Pesty.
proxy.scrollTo(Self.stripStartID, anchor: .leading)
.scrollClipDisabled()
.onChange(of: store.selectedID) { _, id in
guard let id else { return }
if store.initialScrollTargetID == id {
store.initialScrollTargetID = nil
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
// The leading spacer preserves room for the first
// card's focus ring when reopening Pesty.
proxy.scrollTo(Self.stripStartID, anchor: .leading)
}
return
}
withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) {
proxy.scrollTo(id, anchor: .center)
}
return
}
withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) {
proxy.scrollTo(id, anchor: .center)
.overlay {
if store.visibleItems.isEmpty && !showsStackDeck { emptyState }
}
}
.overlay {
if store.visibleItems.isEmpty && !showsStackDeck { emptyState }
}
}
.frame(maxHeight: .infinity)
}
Expand Down
24 changes: 19 additions & 5 deletions Sources/Pesty/UI/ClipPreviewViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,22 @@ struct LinkCardPreview: View {
private var host: String { url?.host ?? text }

var body: some View {
VStack(alignment: .leading, spacing: 8) {
// The normal card shows the rich artwork, while short Paste Bars and
// the anchored-preview strip fall back to a compact version instead
// of forcing the card content to overflow its shared height.
ViewThatFits(in: .vertical) {
previewBody(imageHeight: 104, titleLineLimit: 2, placeholderSize: 26, spacing: 8)
previewBody(imageHeight: 36, titleLineLimit: 1, placeholderSize: 16, spacing: 6)
}
.frame(maxHeight: .infinity, alignment: .top)
.onAppear { previews.load(for: url) }
}

private func previewBody(imageHeight: CGFloat,
titleLineLimit: Int,
placeholderSize: CGFloat,
spacing: CGFloat) -> some View {
VStack(alignment: .leading, spacing: spacing) {
Group {
if let image = preview?.image {
Image(nsImage: image)
Expand All @@ -100,13 +115,13 @@ struct LinkCardPreview: View {
.fill(Color.accentColor.opacity(0.16))
.overlay {
Image(systemName: "link")
.font(.system(size: 26, weight: .medium))
.font(.system(size: placeholderSize, weight: .medium))
.foregroundStyle(Color.accentColor)
}
}
}
.frame(maxWidth: .infinity)
.frame(height: 104)
.frame(height: imageHeight)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))

HStack(spacing: 7) {
Expand All @@ -125,10 +140,9 @@ struct LinkCardPreview: View {
Text(preview?.title ?? host)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textPrimary)
.lineLimit(2)
.lineLimit(titleLineLimit)
}
}
.onAppear { previews.load(for: url) }
}
}

Expand Down