ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.
Two shapes of bug, one per client:
**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.
**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.
So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:
* a game that exits returns to the shelf it was launched from, pin and all,
rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
`ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
drops a pin that was unpinned while the game ran, rather than reopening a card
that no longer exists.
* Android's stats overlay finally names the profile a library launch used
(`profileName` was never set on that path).
* Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
ignored — and refuses an unknown or ambiguous reference exactly as the connect
route does (§10.6) rather than degrading to the binding.
* every shelf says which one it is, in the card's own `host · profile` shape:
the console's title, GTK's page title, Android's ConsoleHeader, Apple's
navigation title and its console heading.
Verified per platform, none of it on trust:
* console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
* Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
pass alone caught `navigationDestination(item:)` needing Hashable, and an
iOS-only screen was confirmed genuinely compiled by planting a type error.
* Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
expectation flipped to match.
* Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
cold (3m10s) — that client cannot compile on a Mac.
458 lines
21 KiB
Swift
458 lines
21 KiB
Swift
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
|
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
|
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
|
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
|
private struct CardMetrics {
|
|
let tile: CGFloat // monogram tile side
|
|
let monogram: CGFloat // monogram letter point size
|
|
let name: CGFloat // host-name point size
|
|
let meta: CGFloat // address (mono) point size
|
|
let status: CGFloat // status-label (mono) point size
|
|
let padding: CGFloat
|
|
let spacing: CGFloat // tile ↔ text gap
|
|
let radius: CGFloat
|
|
|
|
static var current: CardMetrics {
|
|
#if os(iOS)
|
|
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
|
padding: 16, spacing: 14, radius: 12)
|
|
#elseif os(tvOS)
|
|
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
|
|
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
|
|
padding: 24, spacing: 22, radius: 18)
|
|
#else
|
|
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
|
padding: 13, spacing: 12, radius: 10)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
|
private func monogram(_ name: String) -> String {
|
|
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
|
return String(first).uppercased()
|
|
}
|
|
|
|
/// The squared host tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a tinted
|
|
/// outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
|
///
|
|
/// `mark` is the host's OS mark, and it REPLACES the monogram when we have one: it identifies the
|
|
/// machine better than its initial ever did, and on a row of similarly-named boxes the initial says
|
|
/// nothing the name beneath it doesn't already say. A host that advertises no OS chain — or one we
|
|
/// ship no art for — keeps its letter, so a mixed row still reads as one set.
|
|
private func monogramTile(
|
|
_ letter: String, osChain: String?, m: CardMetrics, connecting: Bool, filled: Bool
|
|
) -> some View {
|
|
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
|
return ZStack {
|
|
shape.fill(filled
|
|
? AnyShapeStyle(LinearGradient(
|
|
colors: [Color.brand, Color.brand.opacity(0.72)],
|
|
startPoint: .top, endPoint: .bottom))
|
|
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
|
if connecting {
|
|
ProgressView().tint(filled ? .white : Color.brand)
|
|
} else if let mark = osIconImage(for: osChain) {
|
|
// Template asset — tints from foregroundStyle exactly like the letter it stands in for.
|
|
// Labelled, because this is where the OS is now announced: it used to ride the status
|
|
// row below, which no longer carries it.
|
|
mark
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: m.monogram, height: m.monogram)
|
|
.foregroundStyle(filled ? Color.white : Color.brand)
|
|
.accessibilityLabel(osChain ?? "")
|
|
} else {
|
|
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
|
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
|
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
|
Text(letter)
|
|
.font(.geistFixed(m.monogram, .bold))
|
|
.minimumScaleFactor(0.5)
|
|
.lineLimit(1)
|
|
.foregroundStyle(filled ? Color.white : Color.brand)
|
|
}
|
|
}
|
|
.frame(width: m.tile, height: m.tile)
|
|
.clipShape(shape)
|
|
.overlay {
|
|
if !filled {
|
|
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything a card's profile affordances need: the catalog to offer, what this host is bound
|
|
/// and pinned to, and the acts a menu can perform (design/client-settings-profiles.md §5.2/§5.2a).
|
|
///
|
|
/// Passed as one value rather than eight closures because every surface that renders a host card
|
|
/// has to offer the SAME set — a menu that quietly lacks "Pin as card" on one screen is how a
|
|
/// feature becomes folklore.
|
|
struct HostProfileMenu {
|
|
var profiles: [StreamProfile]
|
|
/// The host's default profile — the chip, and the checkmark in "Connect with ▸".
|
|
var boundID: String?
|
|
var pinnedIDs: [String]
|
|
/// A ONE-OFF connect. Never rebinds: rebinding is `setDefault`, an explicit act (§5.2).
|
|
var connectWith: (ProfileSelection) -> Void
|
|
var setDefault: (String?) -> Void
|
|
var togglePin: (String) -> Void
|
|
/// Copy a `punktfunk://` link for this host, optionally carrying a profile.
|
|
var copyLink: (String?) -> Void
|
|
}
|
|
|
|
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
|
///
|
|
/// The same view renders a PINNED host+profile card (§5.2a): same host, same live status, with
|
|
/// the profile as the prominent subtitle. A pinned card is a shortcut, not a second host, so its
|
|
/// menu carries only connect-shaped actions — edit/pair/forget/remove stay on the primary card,
|
|
/// where the thing they act on actually lives.
|
|
struct HostCardView: View {
|
|
let host: StoredHost
|
|
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
|
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
|
let isOnline: Bool
|
|
let isConnecting: Bool
|
|
let isMostRecent: Bool
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
let onPair: () -> Void
|
|
let onSpeedTest: () -> Void
|
|
let onForget: () -> Void
|
|
let onRemove: () -> Void
|
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
|
var onBrowseLibrary: (() -> Void)? = nil
|
|
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
|
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
|
var onWake: (() -> Void)? = nil
|
|
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
|
|
var onEdit: (() -> Void)? = nil
|
|
/// This card's profile affordances — nil on surfaces that don't offer them.
|
|
var profileMenu: HostProfileMenu? = nil
|
|
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
|
|
/// which follows the binding.
|
|
var pinnedProfile: StreamProfile? = nil
|
|
|
|
/// The profile this card announces: a pinned card's own, else the host's binding.
|
|
private var shownProfile: StreamProfile? {
|
|
pinnedProfile ?? profileMenu.flatMap { menu in
|
|
menu.boundID.flatMap { id in menu.profiles.first { $0.id == id } }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
HStack(spacing: m.spacing) {
|
|
monogramTile(monogram(host.displayName), osChain: host.osChain,
|
|
m: m, connecting: isConnecting, filled: true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// The chip rides the TITLE line, anchored to the card's trailing edge — not
|
|
// trailing the name, where it read as part of the title, and not on a line of
|
|
// its own, where it made cards with a profile taller than cards without and a
|
|
// pinned card stuck out of its grid row. The spacer is what anchors it: the
|
|
// name truncates against that gap instead of ever running into the chip.
|
|
HStack(spacing: 6) {
|
|
Text(host.displayName)
|
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 8)
|
|
if let profile = shownProfile {
|
|
ProfileChip(
|
|
profile: profile, size: m.status,
|
|
prominent: pinnedProfile != nil)
|
|
// The name gives up width first: a truncated host name still reads,
|
|
// a truncated profile name is the one thing the chip exists to say.
|
|
.layoutPriority(1)
|
|
}
|
|
}
|
|
Text("\(host.address):\(String(host.port))")
|
|
.font(.geist(m.meta, relativeTo: .caption))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
statusRow(m)
|
|
}
|
|
// Fills the card so the title row's trailing spacer reaches the real edge; without
|
|
// it the text column hugs its content and "trailing" means "just after the name".
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(m.padding)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#if !os(tvOS)
|
|
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
|
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
|
// and a brand accent bar down the leading edge for the most-recent host.
|
|
.background(.regularMaterial)
|
|
.overlay(alignment: .leading) {
|
|
if isMostRecent {
|
|
Rectangle().fill(Color.brand).frame(width: 3)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
|
.strokeBorder(.quaternary, lineWidth: 1)
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#elseif os(iOS)
|
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
.contextMenu { menuItems }
|
|
}
|
|
|
|
@ViewBuilder private var menuItems: some View {
|
|
if let pinned = pinnedProfile, let menu = profileMenu {
|
|
// A pinned card is a shortcut, not a second host: only connect-shaped actions, plus
|
|
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
|
|
// the host's default binding.
|
|
connectWithMenu(menu)
|
|
// Browsing IS a connect-shaped action — it is this card's connect with a title picked
|
|
// first — so a pinned card offers it and opens its own shelf, whose launches carry the
|
|
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
|
|
// are about the machine, and a shortcut has no business claiming them.)
|
|
if let onBrowseLibrary {
|
|
Button("Browse Library…", action: onBrowseLibrary)
|
|
}
|
|
if LinkClipboard.isAvailable {
|
|
Button("Copy Link") { menu.copyLink(pinned.id) }
|
|
}
|
|
Button("Unpin Card", systemImage: "pin.slash", role: .destructive) {
|
|
menu.togglePin(pinned.id)
|
|
}
|
|
} else {
|
|
if let onEdit {
|
|
Button("Edit…", systemImage: "pencil", action: onEdit)
|
|
}
|
|
if let menu = profileMenu {
|
|
connectWithMenu(menu)
|
|
pinMenu(menu)
|
|
if LinkClipboard.isAvailable {
|
|
Button("Copy Link") { menu.copyLink(nil) }
|
|
}
|
|
}
|
|
Button("Pair with PIN…", action: onPair)
|
|
Button("Test Network Speed…", action: onSpeedTest)
|
|
if let onBrowseLibrary {
|
|
Button("Browse Library…", action: onBrowseLibrary)
|
|
}
|
|
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
|
Button("Wake Host", systemImage: "power", action: onWake)
|
|
}
|
|
if host.pinnedSHA256 != nil {
|
|
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
|
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
|
Button("Forget Identity (re-pair to reconnect)", action: onForget)
|
|
}
|
|
Button("Remove", role: .destructive, action: onRemove)
|
|
}
|
|
}
|
|
|
|
/// "Connect with ▸" — a ONE-OFF pick that never rebinds the host, with a checkmark on what a
|
|
/// plain click would use. Its last item is the explicit way to rebind, for the users who do
|
|
/// want that from here.
|
|
@ViewBuilder private func connectWithMenu(_ menu: HostProfileMenu) -> some View {
|
|
if !menu.profiles.isEmpty {
|
|
Menu("Connect with") {
|
|
Button {
|
|
menu.connectWith(.defaults)
|
|
} label: {
|
|
checkable("Default settings", on: menu.boundID == nil)
|
|
}
|
|
ForEach(menu.profiles) { profile in
|
|
Button {
|
|
menu.connectWith(.profile(profile.id))
|
|
} label: {
|
|
checkable(profile.name, on: menu.boundID == profile.id)
|
|
}
|
|
}
|
|
Divider()
|
|
Menu("Set Default Profile") {
|
|
Button("Default settings") { menu.setDefault(nil) }
|
|
ForEach(menu.profiles) { profile in
|
|
Button(profile.name) { menu.setDefault(profile.id) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "Pin as card ▸" — a host+profile combo gets its own one-click card in the grid, which is
|
|
/// what turns a regularly-used profile from a menu dive into a press (§5.2a).
|
|
@ViewBuilder private func pinMenu(_ menu: HostProfileMenu) -> some View {
|
|
if !menu.profiles.isEmpty {
|
|
Menu("Pin as Card") {
|
|
ForEach(menu.profiles) { profile in
|
|
Button {
|
|
menu.togglePin(profile.id)
|
|
} label: {
|
|
checkable(profile.name, on: menu.pinnedIDs.contains(profile.id))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A menu row that carries a checkmark when it is the current choice. Built as two shapes
|
|
/// rather than one `Label` with an empty symbol name — `Image(systemName: "")` is not a
|
|
/// blank image, it is an invalid one.
|
|
@ViewBuilder private func checkable(_ title: String, on: Bool) -> some View {
|
|
if on {
|
|
Label(title, systemImage: "checkmark")
|
|
} else {
|
|
Text(title)
|
|
}
|
|
}
|
|
|
|
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
|
/// certificate is pinned (the lock state, spelled out).
|
|
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
|
HStack(spacing: 6) {
|
|
// The OS mark used to lead this row; it is the tile's glyph now (see monogramTile).
|
|
RoundedRectangle(cornerRadius: 1.5)
|
|
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
|
.frame(width: 6, height: 6)
|
|
// The state is spelled out in the adjacent text, so the pip is decorative —
|
|
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
|
.accessibilityHidden(true)
|
|
Text(isOnline ? "ONLINE" : "OFFLINE")
|
|
if host.pinnedSHA256 != nil {
|
|
Text("· PAIRED")
|
|
}
|
|
}
|
|
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
|
.tracking(0.8)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
/// The profile a card connects with, as a tinted pill. Quiet on a bound primary card (it only
|
|
/// answers "what will a click do?"); prominent on a pinned card, where the profile IS the reason
|
|
/// the card exists — which is where the catalog's `accent` earns its keep.
|
|
///
|
|
/// Prominence is fill and weight only, never TYPE SIZE: the chip sits on the card's title line,
|
|
/// and a chip taller than the name would make pinned cards taller than their host's.
|
|
struct ProfileChip: View {
|
|
let profile: StreamProfile
|
|
let size: CGFloat
|
|
var prominent = false
|
|
|
|
var body: some View {
|
|
let tint = profile.accentColor
|
|
return HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(tint)
|
|
.frame(width: size * 0.55, height: size * 0.55)
|
|
.accessibilityHidden(true) // the name is right there
|
|
Text(profile.name)
|
|
.font(.geist(size, prominent ? .bold : .semibold, relativeTo: .caption2))
|
|
.lineLimit(1)
|
|
}
|
|
.foregroundStyle(tint)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 2)
|
|
.background(Capsule().fill(tint.opacity(prominent ? 0.24 : 0.12)))
|
|
.accessibilityLabel("Profile \(profile.name)")
|
|
}
|
|
}
|
|
|
|
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
|
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
|
struct DiscoveredCardView: View {
|
|
let discovered: DiscoveredHost
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
HStack(spacing: m.spacing) {
|
|
monogramTile(monogram(discovered.name), osChain: discovered.osChain,
|
|
m: m, connecting: false, filled: false)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(discovered.name)
|
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Text("\(discovered.host):\(String(discovered.port))")
|
|
.font(.geist(m.meta, relativeTo: .caption))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
HStack(spacing: 6) {
|
|
// The advert's OS mark is the tile's glyph now (see monogramTile).
|
|
Image(systemName: discovered.requiresPairing
|
|
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: m.status))
|
|
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
|
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
|
}
|
|
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
|
.tracking(0.8)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(m.padding)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#if !os(tvOS)
|
|
.background(.regularMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
|
.strokeBorder(
|
|
Color.secondary.opacity(0.3),
|
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#elseif os(iOS)
|
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
/// The iOS host-card press/hover treatment, one style for both idioms:
|
|
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
|
/// inert without a pointer.)
|
|
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
|
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
|
/// press scale doubles as click feedback.)
|
|
struct HostCardButtonStyle: ButtonStyle {
|
|
var cornerRadius: CGFloat
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
|
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
|
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
|
.hoverEffect(.highlight)
|
|
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
|
// hardware on iPad → silently ignored there.
|
|
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
|
pressed ? .impact(weight: .light) : nil
|
|
}
|
|
}
|
|
}
|
|
#endif
|