Grouping by profile asked each HOST which profile it was bound to, then handed the answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin, on a host bound to nothing — was filed under "No Profile" along with the rest. Every pinned card was, which is most of what the grouping exists to separate. Pins are not bindings. The arrangement works on CARDS now: a host's own card follows its binding, a pinned card follows its pin, and both can land in the same band when a host pinned the profile it is also bound to. The expansion moved out of the view and into the arrangement, because it is exactly what the grouping has to see. The regression is pinned by a test whose fixture is the shape that broke: a host bound to nothing, carrying two pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
8.8 KiB
Swift
205 lines
8.8 KiB
Swift
// How the host grid is ordered and grouped.
|
|
//
|
|
// Pure value logic, deliberately: which card lands where is the kind of thing that reads as
|
|
// obviously right and behaves subtly wrong (an unstable sort reshuffling equal rows on every
|
|
// redraw, a never-connected host sorting as if it were connected in 1970), so it lives apart from
|
|
// the view and is tested. It sits in PunktfunkShared because everything it touches — `StoredHost`,
|
|
// `ProfileCatalog` — already does.
|
|
//
|
|
// It arranges CARDS, not hosts. A host contributes its own card plus one per pinned profile
|
|
// (§5.2a), and under profile grouping those go to different bands — the pinned card belongs to
|
|
// the profile it connects with, which is the whole reason it exists.
|
|
|
|
import Foundation
|
|
|
|
/// The order hosts appear in.
|
|
public enum HostSort: String, CaseIterable, Identifiable, Sendable {
|
|
/// Oldest first — the order they were added, and what the grid did before it could sort at
|
|
/// all. The default for exactly that reason: an update shouldn't rearrange anyone's grid.
|
|
case added
|
|
case name
|
|
case lastConnected
|
|
|
|
public var id: String { rawValue }
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .added: return "Date Added"
|
|
case .name: return "Name"
|
|
case .lastConnected: return "Last Connected"
|
|
}
|
|
}
|
|
|
|
public var symbol: String {
|
|
switch self {
|
|
case .added: return "calendar"
|
|
case .name: return "textformat.abc"
|
|
case .lastConnected: return "clock.arrow.circlepath"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the grid is divided by.
|
|
public enum HostGrouping: String, CaseIterable, Identifiable, Sendable {
|
|
case none
|
|
/// The profile each CARD connects with: a host's own card follows its binding, a pinned card
|
|
/// follows its pin. Grouping by the binding alone would file every pinned card under "No
|
|
/// Profile" — pins are not bindings, and a pinned card is precisely the one that knows which
|
|
/// profile it means.
|
|
case profile
|
|
case status
|
|
|
|
public var id: String { rawValue }
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .none: return "None"
|
|
case .profile: return "Profile"
|
|
case .status: return "Status"
|
|
}
|
|
}
|
|
|
|
public var symbol: String {
|
|
switch self {
|
|
case .none: return "square.grid.2x2"
|
|
case .profile: return "slider.horizontal.3"
|
|
case .status: return "wifi"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One card in the grid: a host, and the profile that card connects with.
|
|
public struct HostCard: Identifiable, Equatable, Sendable {
|
|
public let host: StoredHost
|
|
/// nil = the host's own card, which follows whatever it is bound to. Set = a pinned
|
|
/// host+profile card (§5.2a).
|
|
public let pinned: StreamProfile?
|
|
|
|
public var id: String { "\(host.id.uuidString)#\(pinned?.id ?? "")" }
|
|
|
|
public init(host: StoredHost, pinned: StreamProfile? = nil) {
|
|
self.host = host
|
|
self.pinned = pinned
|
|
}
|
|
}
|
|
|
|
/// One rendered band of the grid. `title` is nil for the ungrouped case, which is what tells the
|
|
/// view to draw no header at all rather than an empty one.
|
|
public struct HostGroup: Identifiable, Equatable, Sendable {
|
|
public let id: String
|
|
public let title: String?
|
|
/// `#RRGGBB` when the band is a profile — its chip colour, so the header matches the cards.
|
|
public let accent: String?
|
|
public let cards: [HostCard]
|
|
|
|
public init(id: String, title: String?, accent: String? = nil, cards: [HostCard]) {
|
|
self.id = id
|
|
self.title = title
|
|
self.accent = accent
|
|
self.cards = cards
|
|
}
|
|
}
|
|
|
|
public enum HostArrangement {
|
|
/// Group, then sort within each group. `online` is the set of host ids currently reachable —
|
|
/// the caller owns that (mDNS plus the reachability probe), this only reads it.
|
|
public static func groups(
|
|
hosts: [StoredHost], catalog: ProfileCatalog, online: Set<UUID>,
|
|
sort: HostSort, grouping: HostGrouping
|
|
) -> [HostGroup] {
|
|
// The original index is the tie-break for every sort below. `Array.sorted` is NOT stable
|
|
// in Swift, so without it two hosts that compare equal — same name, both never connected —
|
|
// could swap places between redraws.
|
|
let ranked = hosts.enumerated().map { (index: $0.offset, host: $0.element) }
|
|
|
|
func ordered(_ slice: [(index: Int, host: StoredHost)]) -> [StoredHost] {
|
|
slice.sorted { lhs, rhs in
|
|
switch sort {
|
|
case .added:
|
|
// Undated hosts first: no date means saved before the field existed, which
|
|
// means older than anything carrying one. In a real store those are a PREFIX
|
|
// (everything from before the upgrade), so they hold their stored order and
|
|
// the grid looks exactly as it did — which is why this is the default sort.
|
|
let a = lhs.host.addedAt, b = rhs.host.addedAt
|
|
if let a, let b, a != b { return a < b }
|
|
if (a == nil) != (b == nil) { return a == nil }
|
|
case .name:
|
|
let comparison = lhs.host.displayName.localizedStandardCompare(
|
|
rhs.host.displayName)
|
|
if comparison != .orderedSame { return comparison == .orderedAscending }
|
|
case .lastConnected:
|
|
// Never-connected hosts go last, not first — `.distantPast` would put a host
|
|
// you have never used at the top of "Last Connected".
|
|
switch (lhs.host.lastConnected, rhs.host.lastConnected) {
|
|
case let (a?, b?) where a != b: return a > b
|
|
case (nil, _?): return false
|
|
case (_?, nil): return true
|
|
default: break
|
|
}
|
|
}
|
|
return lhs.index < rhs.index
|
|
}.map(\.host)
|
|
}
|
|
|
|
/// A host's own card, then one per pinned profile — so a pin reads as belonging to its
|
|
/// host wherever the two sit together.
|
|
func cards(_ host: StoredHost) -> [HostCard] {
|
|
[HostCard(host: host)] + catalog.pinned(for: host).map { HostCard(host: host, pinned: $0) }
|
|
}
|
|
|
|
switch grouping {
|
|
case .none:
|
|
return [HostGroup(id: "all", title: nil, cards: ordered(ranked).flatMap(cards))]
|
|
|
|
case .profile:
|
|
// Catalog order, so the bands sit in the same order the scope menu lists them, then
|
|
// whatever belongs to no profile. A band with nothing in it isn't drawn.
|
|
let sorted = ordered(ranked)
|
|
var groups: [HostGroup] = catalog.profiles.compactMap { profile in
|
|
let members: [HostCard] = sorted.flatMap { host -> [HostCard] in
|
|
var found: [HostCard] = []
|
|
// A host BOUND to this profile brings its own card…
|
|
if catalog.binding(for: host)?.id == profile.id {
|
|
found.append(HostCard(host: host))
|
|
}
|
|
// …and a host that PINNED it brings that pinned card, whatever it is bound
|
|
// to. Both can be true: a bound profile may also be pinned.
|
|
if let pin = catalog.pinned(for: host).first(where: { $0.id == profile.id }) {
|
|
found.append(HostCard(host: host, pinned: pin))
|
|
}
|
|
return found
|
|
}
|
|
guard !members.isEmpty else { return nil }
|
|
return HostGroup(
|
|
id: "profile-\(profile.id)", title: profile.name, accent: profile.accent,
|
|
cards: members)
|
|
}
|
|
// Hosts that a plain tap streams with the defaults. A dangling binding resolves as
|
|
// none (§4.4), so it lands here rather than in a band named after a profile that no
|
|
// longer exists.
|
|
let unbound = sorted.filter { catalog.binding(for: $0) == nil }.map { HostCard(host: $0) }
|
|
if !unbound.isEmpty {
|
|
groups.append(
|
|
HostGroup(id: "profile-none", title: "No Profile", cards: unbound))
|
|
}
|
|
return groups
|
|
|
|
case .status:
|
|
var groups: [HostGroup] = []
|
|
let up = ranked.filter { online.contains($0.host.id) }
|
|
let down = ranked.filter { !online.contains($0.host.id) }
|
|
if !up.isEmpty {
|
|
groups.append(
|
|
HostGroup(id: "status-online", title: "Online",
|
|
cards: ordered(up).flatMap(cards)))
|
|
}
|
|
if !down.isEmpty {
|
|
groups.append(
|
|
HostGroup(id: "status-offline", title: "Offline",
|
|
cards: ordered(down).flatMap(cards)))
|
|
}
|
|
return groups
|
|
}
|
|
}
|
|
}
|