From f071467cbbe963a047e6c3f3961601b8246eea49 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 11:31:46 +0200 Subject: [PATCH] feat(client/apple): sort and group the host grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid had no ordering story — hosts sat in the order they were added and that was the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected) and Group by (None, Profile, Status), both per device: this is a window on a list, not something about how a host streams. The control stays out of the toolbar until there are two hosts to arrange. Grouping by profile is the one the profiles made possible: bands in catalog order, each header wearing its profile's colour, then the unbound hosts. Pinned cards stay with their host — a pin is presentation of that host, not a host of its own — and a dangling binding lands in "No Profile" rather than in a band named after a profile that no longer exists. Empty bands aren't drawn. The ordering is pure and tested, which earned its keep immediately: the test caught that undated hosts were sorting first, and made me work out whether that was a bug. It isn't — no date means saved before `addedAt` existed, which means older, and in a real store those are a prefix, so the default sort leaves an upgraded grid exactly as it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so every comparison tie-breaks on the stored index or equal rows swap on redraw; and a never-connected host sorts LAST under Last Connected, not first as `.distantPast` would have it. `StoredHost` gains `addedAt` for the date the sort actually needs — optional and appended last, so the widget contract holds and hosts saved before it keep working. Co-Authored-By: Claude Opus 5 (1M context) --- .../PunktfunkClient/Home/HomeView.swift | 94 +++++++++- .../PunktfunkClient/Stores/HostStore.swift | 5 + .../PunktfunkShared/DefaultsKeys.swift | 5 + .../PunktfunkShared/HostArrangement.swift | 160 ++++++++++++++++++ .../Sources/PunktfunkShared/StoredHost.swift | 7 +- .../SharedFoundationTests.swift | 114 ++++++++++++- 6 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkShared/HostArrangement.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 46499024..76f9e61b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -44,6 +44,10 @@ struct HomeView: View { @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true /// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet. @State private var editTarget: StoredHost? + // How this device shows its own list. `.added` is the default because it is what the grid + // did before it could sort at all — an update should not rearrange anyone's hosts. + @AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue + @AppStorage(DefaultsKey.hostGrouping) private var groupingRaw = HostGrouping.none.rawValue var body: some View { NavigationStack { @@ -53,9 +57,16 @@ struct HomeView: View { } else { ScrollView { if !store.hosts.isEmpty { - LazyVGrid(columns: gridColumns, spacing: gridSpacing) { - ForEach(gridEntries) { entry in - hostCard(entry.host, pinned: entry.profile) + LazyVStack(alignment: .leading, spacing: gridSpacing) { + ForEach(hostGroups) { group in + if let title = group.title { + groupHeader(title, accent: group.accent) + } + LazyVGrid(columns: gridColumns, spacing: gridSpacing) { + ForEach(entries(in: group)) { entry in + hostCard(entry.host, pinned: entry.profile) + } + } } } .padding() @@ -137,8 +148,17 @@ struct HomeView: View { #if os(iOS) // Adjacent trailing items share one glass pill (the system default). ToolbarItem(placement: .topBarTrailing) { settingsButton } + if showsArrangeMenu { + ToolbarItem(placement: .topBarTrailing) { arrangeMenu } + } ToolbarItem(placement: .topBarTrailing) { addHostButton } #else + if showsArrangeMenu { + ToolbarItem(placement: .primaryAction) { + arrangeMenu + .help("Sort and group the host list") + } + } ToolbarItem(placement: .primaryAction) { addHostButton .help("Add a host") @@ -199,22 +219,52 @@ struct HomeView: View { var id: String { "\(host.id.uuidString)#\(profile?.id ?? "")" } } - /// Saved hosts, each immediately followed by its pinned cards — so a pin reads as belonging to - /// its host rather than as a stray tile somewhere in the grid. - private var gridEntries: [HostGridEntry] { - store.hosts.flatMap { host in + /// The grid's bands, ordered and divided per this device's preference (`HostArrangement`). + private var hostGroups: [HostGroup] { + HostArrangement.groups( + hosts: store.hosts, catalog: profiles.catalog, + online: Set(store.hosts.filter(isOnline).map(\.id)), + sort: HostSort(rawValue: sortRaw) ?? .added, + grouping: HostGrouping(rawValue: groupingRaw) ?? .none) + } + + /// A band's hosts, each immediately followed by its pinned cards — so a pin reads as belonging + /// to its host rather than as a stray tile somewhere in the grid. + private func entries(in group: HostGroup) -> [HostGridEntry] { + group.hosts.flatMap { host in [HostGridEntry(host: host, profile: nil)] + profiles.pinned(for: host).map { HostGridEntry(host: host, profile: $0) } } } + /// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never + /// advertises). One definition, used by the cards and by the Status grouping alike. + private func isOnline(_ host: StoredHost) -> Bool { + discovery.advertises(host) || store.probedOnline.contains(host.id) + } + + private func groupHeader(_ title: String, accent: String?) -> some View { + HStack(spacing: 7) { + Circle() + .fill(Color(hex: accent ?? "") ?? Color.secondary.opacity(0.5)) + .frame(width: 7, height: 7) + .accessibilityHidden(true) // the title says it + Text(title) + .font(.geist(13, .semibold, relativeTo: .subheadline)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + } + .padding(.top, 4) + } + private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View { let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil // A pinned card connects with ITS profile; the primary card follows the binding. let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit return HostCardView( host: host, - isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), + isOnline: isOnline(host), isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, // The accent ring marks the most recent HOST; pinned cards stay quiet so the grid // doesn't grow several "most recent" bars for one machine. @@ -311,6 +361,34 @@ struct HomeView: View { } } + #if !os(tvOS) + /// One host has no order and nothing to divide, so the control stays out of the way until + /// there is a list to arrange. + private var showsArrangeMenu: Bool { store.hosts.count > 1 } + + /// Sort and group, as two inline pickers in one menu — both are about the same list, and + /// splitting them across two toolbar items would say otherwise. + private var arrangeMenu: some View { + Menu { + Picker("Sort By", selection: $sortRaw) { + ForEach(HostSort.allCases) { option in + Label(option.label, systemImage: option.symbol).tag(option.rawValue) + } + } + .pickerStyle(.inline) + Divider() + Picker("Group By", selection: $groupingRaw) { + ForEach(HostGrouping.allCases) { option in + Label(option.label, systemImage: option.symbol).tag(option.rawValue) + } + } + .pickerStyle(.inline) + } label: { + Label("Sort and Group", systemImage: "line.3.horizontal.decrease.circle") + } + } + #endif + #if !os(macOS) private var settingsButton: some View { Button { diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 2da19a73..15bab6f8 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -97,6 +97,11 @@ final class HostStore: ObservableObject { } func add(_ host: StoredHost) { + var host = host + // Stamped here rather than in the initializer: a `StoredHost` is also built to describe a + // host we are only dialing (the dev auto-connect hook, a deep link's confirmation), and + // those were never added to anything. + host.addedAt = host.addedAt ?? Date() hosts.append(host) } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index fa1a32a3..b4210b0c 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -94,6 +94,11 @@ public enum DefaultsKey { /// stays 4:2:0). Sharper text/UI at the cost of more bandwidth. public static let enable444 = "punktfunk.enable444" public static let hosts = "punktfunk.hosts" + /// How the host grid is ordered (a `HostSort` raw value) and what it's divided by (a + /// `HostGrouping`). Per device, never per profile: it is this device's window on its own + /// list, not something about how a host is streamed. + public static let hostSort = "punktfunk.hostSort" + public static let hostGrouping = "punktfunk.hostGrouping" /// The settings-profile catalog (`ProfileCatalog`, one JSON blob) — design /// client-settings-profiles.md §4.2. Lives in the APP GROUP suite with `hosts`, not with the /// settings: bindings and pins are fields on the host record, and an extension that can read diff --git a/clients/apple/Sources/PunktfunkShared/HostArrangement.swift b/clients/apple/Sources/PunktfunkShared/HostArrangement.swift new file mode 100644 index 00000000..616a618c --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/HostArrangement.swift @@ -0,0 +1,160 @@ +// How the host grid is ordered and grouped. +// +// Pure value logic, deliberately: which host 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. + +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 host's DEFAULT profile — the one a plain tap uses. Pinned cards stay with their host: + /// a pin is presentation of that host, not a host of its own. + 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 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 hosts: [StoredHost] + + public init(id: String, title: String?, accent: String? = nil, hosts: [StoredHost]) { + self.id = id + self.title = title + self.accent = accent + self.hosts = hosts + } +} + +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, + 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) + } + + switch grouping { + case .none: + return [HostGroup(id: "all", title: nil, hosts: ordered(ranked))] + + case .profile: + // Catalog order, so the bands sit in the same order the scope menu lists them, then + // the unbound hosts. A band with nothing in it isn't drawn. + var groups: [HostGroup] = catalog.profiles.compactMap { profile in + let members = ranked.filter { catalog.binding(for: $0.host)?.id == profile.id } + guard !members.isEmpty else { return nil } + return HostGroup( + id: "profile-\(profile.id)", title: profile.name, accent: profile.accent, + hosts: ordered(members)) + } + // 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 = ranked.filter { catalog.binding(for: $0.host) == nil } + if !unbound.isEmpty { + groups.append( + HostGroup(id: "profile-none", title: "No Profile", hosts: ordered(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", hosts: ordered(up))) + } + if !down.isEmpty { + groups.append( + HostGroup(id: "status-offline", title: "Offline", hosts: ordered(down))) + } + return groups + } + } +} diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index 5ca84dd0..46697a33 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -49,12 +49,16 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable { /// default — that is `profileID`; a pin is presentation only, and duplicates and dangling ids /// are dropped when the cards are built. Optional for the same forward-compat reason. public var pinnedProfileIDs: [String]? + /// When this host was saved — what the grid's "Date Added" sort orders by. Optional and + /// appended last for the same widget-contract reason as the rest; hosts saved before it + /// existed have none, and keep their stored order, which IS the order they were added in. + public var addedAt: Date? public init( id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777, pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil, macAddresses: [String]? = nil, clipboardSync: Bool? = nil, - profileID: String? = nil, pinnedProfileIDs: [String]? = nil + profileID: String? = nil, pinnedProfileIDs: [String]? = nil, addedAt: Date? = nil ) { self.id = id self.name = name @@ -67,6 +71,7 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable { self.clipboardSync = clipboardSync self.profileID = profileID self.pinnedProfileIDs = pinnedProfileIDs + self.addedAt = addedAt } public var displayName: String { name.isEmpty ? address : name } diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index 0e84ef56..37a8fcec 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -24,7 +24,8 @@ final class SharedFoundationTests: XCTestCase { pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]), lastConnected: Date(timeIntervalSince1970: 1_700_000_000), mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true, - profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"]) + profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"], + addedAt: Date(timeIntervalSince1970: 1_600_000_000)) let data = try JSONEncoder().encode(host) let decoded = try JSONDecoder().decode(StoredHost.self, from: data) @@ -49,6 +50,7 @@ final class SharedFoundationTests: XCTestCase { XCTAssertNil(decoded.clipboardSync) XCTAssertNil(decoded.profileID) XCTAssertNil(decoded.pinnedProfileIDs) + XCTAssertNil(decoded.addedAt) // Resolvers fall back cleanly. XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort) XCTAssertEqual(decoded.wakeMacs, []) @@ -212,6 +214,116 @@ final class SharedFoundationTests: XCTestCase { XCTAssertFalse(lying.pinConflict(with: couchA)) } + // MARK: - Host grid arrangement + + private func arrangementFixture() -> (hosts: [StoredHost], catalog: ProfileCatalog) { + let catalog = ProfileCatalog(profiles: [ + StreamProfile(name: "Game", id: "111111111111", accent: "#ff8800"), + StreamProfile(name: "Work", id: "222222222222"), + ]) + // Stored order is the order they were added. Basement has no `addedAt` at all — it was + // saved before the field existed, which is why it sits at the FRONT of the store: undated + // hosts are always the oldest ones, never scattered through the middle. + let basement = StoredHost(name: "Basement", address: "10.0.0.3") + var desk = StoredHost(name: "Desk", address: "10.0.0.1", + addedAt: Date(timeIntervalSince1970: 100)) + desk.profileID = "111111111111" + desk.lastConnected = Date(timeIntervalSince1970: 5_000) + var attic = StoredHost(name: "attic", address: "10.0.0.2", + addedAt: Date(timeIntervalSince1970: 200)) + attic.profileID = "222222222222" + var couch = StoredHost(name: "Couch", address: "10.0.0.4", + addedAt: Date(timeIntervalSince1970: 300)) + couch.lastConnected = Date(timeIntervalSince1970: 9_000) + return ([basement, desk, attic, couch], catalog) + } + + private func arranged( + _ sort: HostSort, _ grouping: HostGrouping = .none, online: Set = [] + ) -> [HostGroup] { + let (hosts, catalog) = arrangementFixture() + return HostArrangement.groups( + hosts: hosts, catalog: catalog, online: online, sort: sort, grouping: grouping) + } + + /// The default is the order the grid had before it could sort at all — an update must not + /// rearrange anyone's hosts. + /// + /// Undated hosts sort FIRST, because having no date means predating the field means being + /// older. In a real store they are a prefix (everything saved before the upgrade), so the + /// result is the stored order exactly — which is the point. + func testHostSortByDateAddedKeepsTheStoredOrder() { + let (hosts, _) = arrangementFixture() + let group = arranged(.added)[0] + XCTAssertNil(group.title, "ungrouped draws no header") + XCTAssertEqual(group.hosts.map(\.name), hosts.map(\.name)) + XCTAssertEqual(group.hosts.map(\.name), ["Basement", "Desk", "attic", "Couch"]) + } + + /// Case- and locale-insensitive, so "attic" doesn't sort after "Desk" the way a raw `<` on + /// Strings would put every lowercase name below every uppercase one. + func testHostSortByNameIgnoresCase() { + XCTAssertEqual( + arranged(.name)[0].hosts.map(\.name), ["attic", "Basement", "Couch", "Desk"]) + } + + /// Most recent first — and a host you have NEVER connected to goes last, not first. Treating + /// "never" as `.distantPast` would sort it as if it had been connected in 1970. + func testHostSortByLastConnectedPutsNeverConnectedLast() { + let hosts = arranged(.lastConnected)[0].hosts + XCTAssertEqual(hosts.prefix(2).map(\.name), ["Couch", "Desk"]) + // The two never-connected ones tie, so they keep their stored order — `sorted` is not + // stable in Swift, and equal rows swapping between redraws is a visible bug. + XCTAssertEqual(hosts.suffix(2).map(\.name), ["Basement", "attic"]) + } + + /// Bands in catalog order, then the unbound ones. An empty band isn't drawn at all. + func testHostGroupingByProfile() { + let groups = arranged(.name, .profile) + XCTAssertEqual(groups.map(\.title), ["Game", "Work", "No Profile"]) + XCTAssertEqual(groups[0].accent, "#ff8800", "the header matches its cards") + XCTAssertEqual(groups[0].hosts.map(\.name), ["Desk"]) + XCTAssertEqual(groups[2].hosts.map(\.name), ["Basement", "Couch"]) + + // A profile nobody uses gets no band. + let unused = ProfileCatalog(profiles: [StreamProfile(name: "Travel", id: "333333333333")]) + let (hosts, _) = arrangementFixture() + let none = HostArrangement.groups( + hosts: hosts, catalog: unused, online: [], sort: .name, grouping: .profile) + XCTAssertEqual(none.map(\.title), ["No Profile"], "every host is unbound in this catalog") + } + + /// A dangling binding resolves as no profile (§4.4), so it lands in the unbound band rather + /// than in one named after a profile that no longer exists. + func testHostGroupingDropsDanglingBindings() { + var host = StoredHost(name: "Desk", address: "10.0.0.1") + host.profileID = "deadbeefdead" + let groups = HostArrangement.groups( + hosts: [host], catalog: ProfileCatalog(), online: [], sort: .name, grouping: .profile) + XCTAssertEqual(groups.map(\.title), ["No Profile"]) + } + + func testHostGroupingByStatus() { + // One fixture, reused: each call mints fresh UUIDs, so an `online` set taken from a + // second fixture would name hosts this one has never heard of. + let (hosts, catalog) = arrangementFixture() + func groups(online: Set) -> [HostGroup] { + HostArrangement.groups( + hosts: hosts, catalog: catalog, online: online, sort: .name, grouping: .status) + } + + // By name, not by index: the fixture's order is a fact about `.added`, not about this. + let up = hosts.filter { ["Desk", "Couch"].contains($0.name) }.map(\.id) + let some = groups(online: Set(up)) + XCTAssertEqual(some.map(\.title), ["Online", "Offline"]) + XCTAssertEqual(some[0].hosts.map(\.name), ["Couch", "Desk"]) + XCTAssertEqual(some[1].hosts.map(\.name), ["attic", "Basement"]) + + // All offline: no empty "Online" band. + XCTAssertEqual(groups(online: []).map(\.title), ["Offline"]) + XCTAssertEqual(groups(online: Set(hosts.map(\.id))).map(\.title), ["Online"]) + } + // MARK: - Settings profiles /// The overlay applies field by field: a value wins, an absent one keeps the base's live