Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
T
enricobuehlerandClaude Opus 5 afb33d510f fix(client/apple): anchor the profile chip to the card's trailing edge
On the title line it sat immediately after the host name, so it read as part of the
title rather than as a badge on the card. A spacer between them anchors it to the
trailing edge, and the text column now fills the card — without that it hugs its
content and "trailing" only ever means "just after the name".

The spacer is also what keeps the two apart: the host name truncates against the gap
instead of running into the chip, and the chip keeps layout priority because a
truncated host name still reads while a truncated profile name is the one thing the
chip exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

444 lines
20 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 monogram 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.
private func monogramTile(_ letter: 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 {
// 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), 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)
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) {
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 ?? Color.brand
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)")
}
}
extension StreamProfile {
/// The `#RRGGBB` accent as a colour, or nil when the profile hasn't been given one (the
/// schema reserves the field; every surface falls back to the brand tint).
var accentColor: Color? {
guard let accent, accent.hasPrefix("#"), accent.count == 7,
let value = UInt32(accent.dropFirst(), radix: 16)
else { return nil }
return Color(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255)
}
}
/// 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), 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) {
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