From df552061c6e3d526890043f1578a45c4d127cede Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:05:42 +0200 Subject: [PATCH 01/14] =?UTF-8?q?Shorten=20grouped=20list=20titles=20to=20?= =?UTF-8?q?"Xcode=2027"=20>=20"27.0"=20>=20"27.0=20=E2=80=93=20Beta=206"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the top level repeats "Xcode". Rows under a version group show the prerelease after a dash and move the build number to the caption line next to the install path. The flat list is unchanged. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 7 ++-- .../Frontend/XcodeList/XcodeListViewRow.swift | 41 +++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 12cfdb05..ec3ec7a9 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -124,7 +124,7 @@ private struct GroupedXcodeListContent: View { let latestInstalledMajorVersion = majorVersions.latestInstalledVersion XcodeVersionGroupRow( - displayName: majorVersionGroup.displayName, + displayName: "Xcode \(majorVersionGroup.displayName)", latestRelease: latestMajorRelease, latestSelectableRelease: latestMajorRelease, latestSelectionTarget: latestInstalledMajorVersion, @@ -188,7 +188,8 @@ private struct GroupedXcodeListContent: View { xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode), + style: .grouped ) .padding(.leading, 40) .tag(entry.xcode.id) @@ -224,7 +225,7 @@ private struct XcodeVersionGroupRow: View { icon VStack(alignment: .leading, spacing: 2) { - Text(verbatim: "Xcode \(displayName)") + Text(verbatim: displayName) .font(.body.weight(indentation == 0 ? .medium : .regular)) if let latestRelease { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 2d86e634..1b37c99c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -4,16 +4,49 @@ import Version import XcodesKit struct XcodeListViewRow: View { + enum Style { + /// "27.0 Beta 6 (27A5252f)", used by the flat list + case flat + /// "27.0 – Beta 6" with the build moved to the caption, used under a version group + case grouped + } + let xcode: Xcode let selected: Bool let appState: AppState let latestReleaseForSelectedPrerelease: Xcode? + let style: Style - init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil) { + init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil, style: Style = .flat) { self.xcode = xcode self.selected = selected self.appState = appState self.latestReleaseForSelectedPrerelease = latestReleaseForSelectedPrerelease + self.style = style + } + + private var title: String { + switch style { + case .flat: + return "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)" + case .grouped: + let version = xcode.version + let base = Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription + let prerelease = xcode.description.dropFirst(base.count).trimmingCharacters(in: .whitespaces) + return prerelease.isEmpty ? base : "\(base) – \(prerelease)" + } + } + + /// Secondary line: the build (grouped style only) and the install path, when present. + private var caption: String? { + var parts: [String] = [] + if style == .grouped { + parts.append(contentsOf: xcode.version.buildMetadataIdentifiers) + } + if case let .installed(path) = xcode.installState { + parts.append(path.string) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } var body: some View { @@ -22,7 +55,7 @@ struct XcodeListViewRow: View { VStack(alignment: .leading) { HStack { - Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") + Text(verbatim: title) .font(.body) if !xcode.identicalBuildsForCurrentVariant.isEmpty { @@ -43,8 +76,8 @@ struct XcodeListViewRow: View { } } - if case let .installed(path) = xcode.installState { - Text(verbatim: path.string) + if let caption { + Text(verbatim: caption) .font(.caption) .foregroundColor(.secondary) } From 9d46d0b878754b3deecbf30080ca94831b69e316 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:05:42 +0200 Subject: [PATCH 02/14] Make grouped list levels visually distinct with tree guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor version rows and their versions were indented by the same amount, since leaf rows have no chevron. Each level is now one step further in, connected by tree guides (├ └ │) tinted along the branch that contains the active Xcode, and group rows show how many versions they contain. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 127 ++++++++++++++++-- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index ec3ec7a9..810a556b 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -122,6 +122,8 @@ private struct GroupedXcodeListContent: View { let majorVersions = majorVersionGroup.versions.map(\.xcode) let latestMajorRelease = majorVersions.latestRelease let latestInstalledMajorVersion = majorVersions.latestInstalledVersion + let majorHasActiveXcode = majorVersions.contains { $0.selected } + let minorVersionGroups = majorVersionGroup.minorVersionGroups XcodeVersionGroupRow( displayName: "Xcode \(majorVersionGroup.displayName)", @@ -131,7 +133,8 @@ private struct GroupedXcodeListContent: View { selectedVersion: majorVersions.first { $0.selected }, installingVersion: majorVersions.first { $0.installState.installing }, isExpanded: isMajorExpanded, - indentation: 0, + level: 0, + versionCount: majorVersions.count, appState: appState, onToggleExpanded: { var updatedExpandedMajorVersions = expandedMajorVersions @@ -153,10 +156,12 @@ private struct GroupedXcodeListContent: View { .tag(majorVersions.first { $0.selected }?.id) if isMajorExpanded { - ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in + ForEach(Array(minorVersionGroups.enumerated()), id: \.element.id) { minorIndex, minorVersionGroup in let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id) let minorVersions = minorVersionGroup.versions.map(\.xcode) let latestInstalledMinorVersion = minorVersions.latestInstalledVersion + let isLastMinor = minorIndex == minorVersionGroups.count - 1 + let minorHasActiveXcode = minorVersions.contains { $0.selected } XcodeVersionGroupRow( displayName: minorVersionGroup.displayName, @@ -166,7 +171,8 @@ private struct GroupedXcodeListContent: View { selectedVersion: minorVersions.first { $0.selected }, installingVersion: minorVersions.first { $0.installState.installing }, isExpanded: isMinorExpanded, - indentation: 20, + level: 1, + versionCount: minorVersions.count, appState: appState, onToggleExpanded: { var updatedExpandedMinorVersions = expandedMinorVersions @@ -180,10 +186,18 @@ private struct GroupedXcodeListContent: View { self.expandedMinorVersions = updatedExpandedMinorVersions } ) + .background(alignment: .leading) { + TreeGuides(guides: [ + TreeGuide(level: 0, extent: isLastMinor ? .elbow : .tee, isHighlighted: majorHasActiveXcode && (minorHasActiveXcode || !isLastMinor)) + ]) + } .tag(minorVersions.first { $0.selected }?.id) if isMinorExpanded { - ForEach(minorVersionGroup.versions) { entry in + let entries = minorVersionGroup.versions + ForEach(Array(entries.enumerated()), id: \.element.id) { entryIndex, entry in + let isLastEntry = entryIndex == entries.count - 1 + XcodeListViewRow( xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, @@ -191,7 +205,13 @@ private struct GroupedXcodeListContent: View { latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode), style: .grouped ) - .padding(.leading, 40) + .padding(.leading, TreeGuide.contentInset(forLevel: 2)) + .background(alignment: .leading) { + TreeGuides(guides: [ + TreeGuide(level: 0, extent: isLastMinor ? .none : .through, isHighlighted: majorHasActiveXcode && !isLastMinor), + TreeGuide(level: 1, extent: isLastEntry ? .elbow : .tee, isHighlighted: minorHasActiveXcode, branchesToLeaf: true) + ]) + } .tag(entry.xcode.id) } } @@ -201,6 +221,82 @@ private struct GroupedXcodeListContent: View { } } +/// A vertical guide line connecting a group row to its children, drawn like a file tree (├ └ │). +private struct TreeGuide: Hashable { + enum Extent { + /// No line; the parent's last child has already been drawn. + case none + /// A full-height line passing a sibling's descendants. + case through + /// A full-height line with a branch to this row (├). + case tee + /// A line ending at this row with a branch to it (└). + case elbow + } + + /// Chevron width plus spacing, so each level's content starts one step to the right of its parent's chevron. + static let levelIndent: CGFloat = 22 + static let chevronWidth: CGFloat = 12 + + let level: Int + let extent: Extent + let isHighlighted: Bool + /// Whether this row is a leaf (no chevron), so the branch reaches further to meet its icon. + var branchesToLeaf = false + + /// Centered under the parent's chevron. + var x: CGFloat { + CGFloat(level) * Self.levelIndent + Self.chevronWidth / 2 + } + + /// Where the branch drawn for this row stops, just short of the row's chevron or icon. + var branchEnd: CGFloat { + let childContentStart = branchesToLeaf ? Self.contentInset(forLevel: level + 1) : CGFloat(level + 1) * Self.levelIndent + return childContentStart - 4 + } + + /// Leaf rows have no chevron, so their icon is inset to where a chevron-less row at that level would start. + static func contentInset(forLevel level: Int) -> CGFloat { + CGFloat(level) * levelIndent + chevronWidth + 8 + } +} + +private struct TreeGuides: View { + let guides: [TreeGuide] + + var body: some View { + Canvas { context, size in + let lineWidth: CGFloat = 1.5 + // Rows have a little vertical breathing room; extend past it so lines connect between rows. + let overshoot: CGFloat = 4 + let midY = size.height / 2 + for guide in guides { + let color: Color = guide.isHighlighted ? .accentColor.opacity(0.75) : .secondary.opacity(0.35) + var path = Path() + switch guide.extent { + case .none: + continue + case .through, .tee: + path.move(to: CGPoint(x: guide.x, y: -overshoot)) + path.addLine(to: CGPoint(x: guide.x, y: size.height + overshoot)) + case .elbow: + path.move(to: CGPoint(x: guide.x, y: -overshoot)) + path.addLine(to: CGPoint(x: guide.x, y: midY - 5)) + path.addQuadCurve(to: CGPoint(x: guide.x + 5, y: midY), control: CGPoint(x: guide.x, y: midY)) + } + if guide.extent == .tee || guide.extent == .elbow { + let branchStart = guide.extent == .tee ? guide.x : guide.x + 5 + path.move(to: CGPoint(x: branchStart, y: midY)) + path.addLine(to: CGPoint(x: guide.branchEnd, y: midY)) + } + context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + private struct XcodeVersionGroupRow: View { let displayName: String let latestRelease: Xcode? @@ -209,7 +305,8 @@ private struct XcodeVersionGroupRow: View { let selectedVersion: Xcode? let installingVersion: Xcode? let isExpanded: Bool - let indentation: CGFloat + let level: Int + let versionCount: Int let appState: AppState let onToggleExpanded: () -> Void @@ -225,8 +322,18 @@ private struct XcodeVersionGroupRow: View { icon VStack(alignment: .leading, spacing: 2) { - Text(verbatim: displayName) - .font(.body.weight(indentation == 0 ? .medium : .regular)) + HStack(spacing: 6) { + Text(verbatim: displayName) + .font(level == 0 ? .headline : .body.weight(.medium)) + + Text(verbatim: "\(versionCount)") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + .accessibilityHidden(true) + } if let latestRelease { Text(verbatim: "Latest: \(latestRelease.description)") @@ -246,8 +353,8 @@ private struct XcodeVersionGroupRow: View { .padding(.trailing, 16) installControl } - .padding(.leading, indentation) - .padding(.vertical, indentation == 0 ? 8 : 6) + .padding(.leading, CGFloat(level) * TreeGuide.levelIndent) + .padding(.vertical, level == 0 ? 8 : 5) .contentShape(Rectangle()) } From 66725ffcce3a41eed17804bc6c2e95e762342325 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:09:03 +0200 Subject: [PATCH 03/14] Tag releases in the grouped list: Beta, RC and Latest Rows under a version group show the prerelease as a tinted tag after the version ("27.0 [Beta 6]", orange for betas, purple for release candidates) and the newest stable release gets a green "Latest" tag. Group rows repeat this: the major version holding the newest release is tagged Latest, and a minor version with only prereleases shows its newest seed, so beta-only versions stand out when collapsed. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Common/TagView.swift | 59 +++++++++++++++++++ Xcodes/Frontend/XcodeList/XcodeListView.swift | 29 ++++++++- .../Frontend/XcodeList/XcodeListViewRow.swift | 19 ++++-- Xcodes/Resources/Localizable.xcstrings | 11 ++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 9d97614e..1bd45a87 100644 --- a/Xcodes/Frontend/Common/TagView.swift +++ b/Xcodes/Frontend/Common/TagView.swift @@ -6,6 +6,8 @@ import SwiftUI +import Version +import XcodesKit struct TagView: View { let text: String @@ -22,3 +24,60 @@ struct TagView: View { ) } } + +/// A tinted capsule that classifies a release at a glance, e.g. Beta, RC or Latest. +struct ReleaseTagView: View { + enum Kind { + case beta + case releaseCandidate + case otherPrerelease + case latest + + var color: Color { + switch self { + case .beta: return .orange + case .releaseCandidate: return .purple + case .otherPrerelease: return .teal + case .latest: return .green + } + } + } + + let label: Text + let kind: Kind + + var body: some View { + label + .font(.caption2.weight(.semibold)) + .foregroundStyle(kind.color) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(kind.color.opacity(0.16), in: Capsule()) + .overlay(Capsule().strokeBorder(kind.color.opacity(0.35), lineWidth: 0.5)) + .fixedSize() + } +} + +extension ReleaseTagView { + /// A tag describing a version's prerelease, e.g. "Beta 6" or "RC 2", or nil for a release. + init?(prereleaseOf version: Version) { + guard version.isPrerelease else { return nil } + let base = Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription + let text = version.appleDescription + .dropFirst(base.count) + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: "Release Candidate", with: "RC") + self.init(label: Text(verbatim: text), kind: Self.kind(forPrerelease: version)) + } + + static func kind(forPrerelease version: Version) -> Kind { + let identifiers = version.prereleaseIdentifiers.joined(separator: " ").lowercased() + if identifiers.contains("beta") { return .beta } + if identifiers.contains("release") || identifiers.contains("rc") || identifiers.contains("gm") { return .releaseCandidate } + return .otherPrerelease + } + + static var latest: ReleaseTagView { + ReleaseTagView(label: Text("Tag.Latest"), kind: .latest) + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 810a556b..1ca8df9b 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -116,6 +116,25 @@ private struct GroupedXcodeListContent: View { allXcodes.latestReleaseForSelectedPrerelease(xcode) } + /// The newest non-prerelease version across the whole list, tagged "Latest" + private var latestStableVersion: Version? { + allXcodes.latestRelease?.version + } + + private func isLatestRelease(_ xcode: Xcode) -> Bool { + guard let latestStableVersion, xcode.version.isNotPrerelease else { return false } + return xcode.version.isEquivalent(to: latestStableVersion) + } + + /// Latest when the group holds the newest release; otherwise, for a group of only prereleases, its newest seed. + private func groupTag(for versions: [Xcode]) -> ReleaseTagView? { + if versions.contains(where: isLatestRelease) { + return .latest + } + guard versions.allSatisfy(\.version.isPrerelease), let newest = versions.max(by: { $0.version < $1.version }) else { return nil } + return ReleaseTagView(prereleaseOf: newest.version) + } + var body: some View { ForEach(majorVersionGroups) { majorVersionGroup in let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion) @@ -135,6 +154,7 @@ private struct GroupedXcodeListContent: View { isExpanded: isMajorExpanded, level: 0, versionCount: majorVersions.count, + tag: majorVersions.contains(where: isLatestRelease) ? .latest : nil, appState: appState, onToggleExpanded: { var updatedExpandedMajorVersions = expandedMajorVersions @@ -173,6 +193,7 @@ private struct GroupedXcodeListContent: View { isExpanded: isMinorExpanded, level: 1, versionCount: minorVersions.count, + tag: groupTag(for: minorVersions), appState: appState, onToggleExpanded: { var updatedExpandedMinorVersions = expandedMinorVersions @@ -203,7 +224,8 @@ private struct GroupedXcodeListContent: View { selected: selectedXcodeID == entry.xcode.id, appState: appState, latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode), - style: .grouped + style: .grouped, + isLatestRelease: isLatestRelease(entry.xcode) ) .padding(.leading, TreeGuide.contentInset(forLevel: 2)) .background(alignment: .leading) { @@ -307,6 +329,7 @@ private struct XcodeVersionGroupRow: View { let isExpanded: Bool let level: Int let versionCount: Int + let tag: ReleaseTagView? let appState: AppState let onToggleExpanded: () -> Void @@ -333,6 +356,10 @@ private struct XcodeVersionGroupRow: View { .padding(.vertical, 1) .background(.quaternary, in: Capsule()) .accessibilityHidden(true) + + if let tag { + tag + } } if let latestRelease { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 1b37c99c..95b83316 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -7,7 +7,7 @@ struct XcodeListViewRow: View { enum Style { /// "27.0 Beta 6 (27A5252f)", used by the flat list case flat - /// "27.0 – Beta 6" with the build moved to the caption, used under a version group + /// "27.0" followed by release tags (Beta 6, RC, Latest), with the build in the caption, used under a version group case grouped } @@ -16,13 +16,15 @@ struct XcodeListViewRow: View { let appState: AppState let latestReleaseForSelectedPrerelease: Xcode? let style: Style + let isLatestRelease: Bool - init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil, style: Style = .flat) { + init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil, style: Style = .flat, isLatestRelease: Bool = false) { self.xcode = xcode self.selected = selected self.appState = appState self.latestReleaseForSelectedPrerelease = latestReleaseForSelectedPrerelease self.style = style + self.isLatestRelease = isLatestRelease } private var title: String { @@ -31,9 +33,7 @@ struct XcodeListViewRow: View { return "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)" case .grouped: let version = xcode.version - let base = Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription - let prerelease = xcode.description.dropFirst(base.count).trimmingCharacters(in: .whitespaces) - return prerelease.isEmpty ? base : "\(base) – \(prerelease)" + return Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription } } @@ -58,6 +58,15 @@ struct XcodeListViewRow: View { Text(verbatim: title) .font(.body) + if style == .grouped { + if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { + prereleaseTag + } + if isLatestRelease { + ReleaseTagView.latest + } + } + if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") .font(.subheadline) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..e1fac673 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -24309,6 +24309,17 @@ } } }, + "Tag.Latest" : { + "comment" : "Tag on the newest non-prerelease Xcode in the list", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest" + } + } + } + }, "TerminateAfterLastWindowClosed" : { "localizations" : { "ar" : { From db9ed30e0a47d77451c9d311689cb198e20385af Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 08:09:03 +0200 Subject: [PATCH 04/14] Show install progress once, as a compact ring While installing, the major group, the minor group and the version row each showed their own spinner with "Step 2 of 7: Downloading...", which was truncated and repeated. Only the deepest visible row shows progress now: an expanded group leaves it to its children. The spinner and text are replaced by an App Store style ring: determinate while downloading, spinning for the other steps, with a stop button in the middle. The step, percentage and download details are in the tooltip, and the info pane still shows the full detail. Co-Authored-By: Claude Opus 5.5 --- .../XcodeList/InstallationStepRowView.swift | 104 ++++++++++++++---- Xcodes/Frontend/XcodeList/XcodeListView.swift | 4 +- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift index 1d605fa0..3862a640 100644 --- a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift +++ b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift @@ -1,40 +1,100 @@ import SwiftUI import XcodesKit +/// A compact, App Store style progress ring for a row: a determinate ring while downloading and a +/// spinning arc for the other steps, with a stop button in the middle. Step details are in the tooltip. struct InstallationStepRowView: View { let installationStep: XcodeInstallationStep let highlighted: Bool let cancel: () -> Void - + var body: some View { - HStack { + Group { switch installationStep { case let .downloading(progress): // FB8955769 ProgressView.init(_: Progress) doesn't ensure that changes from the Progress object are applied to the UI on the main thread - // This Progress is vended by URLSession so I don't think we can control that. - // Use our own version of ProgressView that does this instead. - ObservingProgressIndicator( - progress, - controlSize: .small, - style: .spinning - ) + DownloadProgressRing(progress: progress, highlighted: highlighted, stepDescription: stepDescription, cancel: cancel) case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing: - ProgressView() - .scaleEffect(0.5) + ProgressRing(fraction: nil, highlighted: highlighted, cancel: cancel) + .help(stepDescription) } - - Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message)) - .font(.footnote) - - Button(action: cancel) { - Label("Cancel", systemImage: "xmark.circle.fill") - .labelStyle(IconOnlyLabelStyle()) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(stepDescription)) + } + + private var stepDescription: String { + String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message) + } +} + +private struct DownloadProgressRing: View { + @StateObject private var progress: ObservingProgressIndicator.ProgressWrapper + let highlighted: Bool + let stepDescription: String + let cancel: () -> Void + + init(progress: Progress, highlighted: Bool, stepDescription: String, cancel: @escaping () -> Void) { + _progress = StateObject(wrappedValue: ObservingProgressIndicator.ProgressWrapper(progress: progress)) + self.highlighted = highlighted + self.stepDescription = stepDescription + self.cancel = cancel + } + + var body: some View { + let progress = progress.progress + let percent = String(format: localizeString("DownloadingPercentDescription"), Int(progress.fractionCompleted * 100)) + let details = progress.xcodesLocalizedDescription + ProgressRing(fraction: progress.isIndeterminate ? nil : progress.fractionCompleted, highlighted: highlighted, cancel: cancel) + .help([stepDescription, percent, details].filter { !$0.isEmpty }.joined(separator: "\n")) + } +} + +private struct ProgressRing: View { + /// nil shows an indeterminate spinning arc + let fraction: Double? + let highlighted: Bool + let cancel: () -> Void + + @State private var isHovering = false + @State private var isSpinning = false + + private let size: CGFloat = 24 + private let lineWidth: CGFloat = 2.5 + + var body: some View { + Button(action: cancel) { + ZStack { + Circle() + .stroke(trackColor, lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: fraction.map { max(0.02, min(1, $0)) } ?? 0.25) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(fraction == nil && isSpinning ? 270 : -90)) + .animation(fraction == nil ? .linear(duration: 1).repeatForever(autoreverses: false) : .easeOut(duration: 0.3), value: isSpinning) + .animation(.easeOut(duration: 0.3), value: fraction) + + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(isHovering ? ringColor : ringColor.opacity(0.8)) + .frame(width: 7, height: 7) } - .buttonStyle(PlainButtonStyle()) - .foregroundColor(highlighted ? .white : .secondary) - .help("StopInstallation") + .frame(width: size, height: size) + .contentShape(Circle()) } - .frame(minWidth: 80) + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .onAppear { isSpinning = fraction == nil } + .onChange(of: fraction == nil) { isSpinning = $0 } + .accessibilityLabel(Text("StopInstallation")) + } + + private var ringColor: Color { + highlighted ? .white : .accentColor + } + + private var trackColor: Color { + highlighted ? .white.opacity(0.35) : .secondary.opacity(0.25) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 1ca8df9b..dda0d84a 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -458,7 +458,9 @@ private struct XcodeVersionGroupRow: View { @ViewBuilder private var installControl: some View { - if let installingVersion, + // Progress is shown once, on the deepest visible row: an expanded group leaves it to its children. + if !isExpanded, + let installingVersion, case let .installing(installationStep) = installingVersion.installState { InstallationStepRowView( installationStep: installationStep, From 023e98fbd95a65c89734b6132c293707a3f0935e Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:27:38 +0200 Subject: [PATCH 05/14] Move the build number out of Xcode titles List rows and the info pane title showed the build in parentheses, e.g. "27.2 Beta 2 (27B5028f)". Titles now show only the version; the build moves to the row's caption next to the install path, and to a secondary line under the info pane title. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/InfoPane/InfoPane.swift | 15 +++++++++++---- Xcodes/Frontend/XcodeList/XcodeListViewRow.swift | 11 ++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index b557e748..11ef5f5d 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -24,10 +24,17 @@ struct InfoPane: View { HStack { IconView(xcode: xcode) - Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.title) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "Xcode \(xcode.description)") + .font(.title) + if !xcode.version.buildMetadataIdentifiers.isEmpty { + Text(verbatim: xcode.version.buildMetadataIdentifiers.joined(separator: " ")) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) } InfoPaneControls(xcode: xcode) } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 95b83316..66fe56d2 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -5,7 +5,7 @@ import XcodesKit struct XcodeListViewRow: View { enum Style { - /// "27.0 Beta 6 (27A5252f)", used by the flat list + /// "27.0 Beta 6", used by the flat list case flat /// "27.0" followed by release tags (Beta 6, RC, Latest), with the build in the caption, used under a version group case grouped @@ -30,19 +30,16 @@ struct XcodeListViewRow: View { private var title: String { switch style { case .flat: - return "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)" + return xcode.description case .grouped: let version = xcode.version return Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription } } - /// Secondary line: the build (grouped style only) and the install path, when present. + /// Secondary line: the build and the install path, when present. private var caption: String? { - var parts: [String] = [] - if style == .grouped { - parts.append(contentsOf: xcode.version.buildMetadataIdentifiers) - } + var parts: [String] = xcode.version.buildMetadataIdentifiers if case let .installed(path) = xcode.installState { parts.append(path.string) } From 1a44213984090170ed54aa052c6d8953ef089d2c Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:27:38 +0200 Subject: [PATCH 06/14] Show installed Xcodes with a green checkmark The "make active" checkmark on installed Xcodes was grey, so installed and not-installed rows looked alike at a glance. It is now a green outline; the active Xcode keeps the filled green check. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 3 ++- Xcodes/Frontend/XcodeList/XcodeListViewRow.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index dda0d84a..e446ad2c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -429,8 +429,9 @@ private struct XcodeVersionGroupRow: View { .help("ActiveVersionDescription") } else if let latestSelectionTarget { Button(action: { appState.select(xcode: latestSelectionTarget) }) { + // Installed but not active: green outline; the active Xcode gets the filled check Image(systemName: "checkmark.circle") - .foregroundColor(.secondary) + .foregroundColor(.green) } .buttonStyle(PlainButtonStyle()) .help("MakeActiveVersionDescription") diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 66fe56d2..506b6303 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -169,8 +169,9 @@ struct XcodeListViewRow: View { .help("ActiveVersionDescription") } else { Button(action: { appState.select(xcode: xcode) }) { + // Installed but not active: green outline; the active Xcode gets the filled check Image(systemName: "checkmark.circle") - .foregroundColor(.secondary) + .foregroundColor(.green) } .buttonStyle(PlainButtonStyle()) .help("MakeActiveVersionDescription") From e40ee2423cc9916b0af9901bd9e88a074d599b26 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:45:32 +0200 Subject: [PATCH 07/14] Improve grouped list contrast and show icons only on top-level rows - Only major version rows show the Xcode icon; the nested rows already sit under it, and the repeated icons made the list busy. - Not-installed icons were drawn at 20% opacity and nearly vanished; raise to 50%. - Tags and captions stay legible on the selection highlight: tags turn white on a selected row, and captions and symbols use hierarchical styles instead of Color.secondary. - Group rows show the release tag before the version count. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Common/TagView.swift | 15 ++++++++++++--- Xcodes/Frontend/XcodeList/XcodeListView.swift | 14 ++++++++------ .../Frontend/XcodeList/XcodeListViewRow.swift | 18 +++++++++++------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 1bd45a87..0913a7ca 100644 --- a/Xcodes/Frontend/Common/TagView.swift +++ b/Xcodes/Frontend/Common/TagView.swift @@ -45,17 +45,26 @@ struct ReleaseTagView: View { let label: Text let kind: Kind + /// On a selected row the tint would disappear into the highlight, so draw it in white instead + var isOnSelection = false var body: some View { + let color = isOnSelection ? Color.white : kind.color label .font(.caption2.weight(.semibold)) - .foregroundStyle(kind.color) + .foregroundStyle(color) .padding(.horizontal, 6) .padding(.vertical, 1) - .background(kind.color.opacity(0.16), in: Capsule()) - .overlay(Capsule().strokeBorder(kind.color.opacity(0.35), lineWidth: 0.5)) + .background(color.opacity(isOnSelection ? 0.22 : 0.16), in: Capsule()) + .overlay(Capsule().strokeBorder(color.opacity(isOnSelection ? 0.6 : 0.35), lineWidth: 0.5)) .fixedSize() } + + func onSelection(_ isOnSelection: Bool) -> ReleaseTagView { + var tag = self + tag.isOnSelection = isOnSelection + return tag + } } extension ReleaseTagView { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index e446ad2c..626ea4d0 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -342,13 +342,19 @@ private struct XcodeVersionGroupRow: View { .foregroundColor(.secondary) .frame(width: 12, height: 12) - icon + if level == 0 { + icon + } VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(verbatim: displayName) .font(level == 0 ? .headline : .body.weight(.medium)) + if let tag { + tag + } + Text(verbatim: "\(versionCount)") .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(.secondary) @@ -356,10 +362,6 @@ private struct XcodeVersionGroupRow: View { .padding(.vertical, 1) .background(.quaternary, in: Capsule()) .accessibilityHidden(true) - - if let tag { - tag - } } if let latestRelease { @@ -395,7 +397,7 @@ private struct XcodeVersionGroupRow: View { Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode") .resizable() .frame(width: 32, height: 32) - .opacity(0.2) + .opacity(0.5) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 506b6303..5d774a25 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -48,7 +48,10 @@ struct XcodeListViewRow: View { var body: some View { HStack { - appIconView(for: xcode) + // Rows under a version group rely on the group's icon + if style == .flat { + appIconView(for: xcode) + } VStack(alignment: .leading) { HStack { @@ -57,17 +60,17 @@ struct XcodeListViewRow: View { if style == .grouped { if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { - prereleaseTag + prereleaseTag.onSelection(selected) } if isLatestRelease { - ReleaseTagView.latest + ReleaseTagView.latest.onSelection(selected) } } if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .accessibility(label: Text("IdenticalBuilds")) .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) .help("IdenticalBuilds.help") @@ -76,7 +79,7 @@ struct XcodeListViewRow: View { if xcode.architectures?.isAppleSilicon ?? false { Image(systemName: "m4.button.horizontal") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .accessibility(label: Text("Apple Silicon")) .help("Apple Silicon") } @@ -85,7 +88,8 @@ struct XcodeListViewRow: View { if let caption { Text(verbatim: caption) .font(.caption) - .foregroundColor(.secondary) + // A hierarchical style stays legible on the selection highlight, unlike Color.secondary + .foregroundStyle(.secondary) } } @@ -140,7 +144,7 @@ struct XcodeListViewRow: View { Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode") .resizable() .frame(width: 32, height: 32) - .opacity(0.2) + .opacity(0.5) } } From e31eb690e47f7b7cf1b76e0c674809cc90e6fd5c Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:45:33 +0200 Subject: [PATCH 08/14] Center the install progress ring in the Install/Open button column The ring is narrower than the buttons, so it sat right-aligned and out of line with the buttons above and below it. Give the install control a column as wide as the buttons and center it. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 2 ++ Xcodes/Frontend/XcodeList/XcodeListViewRow.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 626ea4d0..28a05dd0 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -381,6 +381,8 @@ private struct XcodeVersionGroupRow: View { selectControl .padding(.trailing, 16) installControl + // Same column width as the Install/Open buttons, so the progress ring lines up with them + .frame(minWidth: 67) } .padding(.leading, CGFloat(level) * TreeGuide.levelIndent) .padding(.vertical, level == 0 ? 8 : 5) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 5d774a25..9e42152f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -98,6 +98,8 @@ struct XcodeListViewRow: View { selectControl(for: xcode) .padding(.trailing, 16) installControl(for: xcode) + // Same column width as the Install/Open buttons, so the progress ring lines up with them + .frame(minWidth: 67) } .padding(.vertical, 4) .contextMenu { From df89fbdfa8b7261d29e1a7fe88d422ca8c152fbe Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 16:48:42 +0200 Subject: [PATCH 09/14] Tag the active Xcode and name it in group subtitles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active (xcode-select'ed) Xcode gets a blue "Active" tag. While it is hidden inside a collapsed group, the deepest visible group row carries the tag instead, and every group containing it names it in the subtitle: "Latest: 27.0 · Active: 27.0 Beta 6". Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Common/TagView.swift | 6 +++++ Xcodes/Frontend/XcodeList/XcodeListView.swift | 23 ++++++++++++++++--- .../Frontend/XcodeList/XcodeListViewRow.swift | 3 +++ Xcodes/Resources/Localizable.xcstrings | 11 +++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 0913a7ca..8ecf6b4a 100644 --- a/Xcodes/Frontend/Common/TagView.swift +++ b/Xcodes/Frontend/Common/TagView.swift @@ -32,6 +32,7 @@ struct ReleaseTagView: View { case releaseCandidate case otherPrerelease case latest + case active var color: Color { switch self { @@ -39,6 +40,7 @@ struct ReleaseTagView: View { case .releaseCandidate: return .purple case .otherPrerelease: return .teal case .latest: return .green + case .active: return .blue } } } @@ -86,6 +88,10 @@ extension ReleaseTagView { return .otherPrerelease } + static var active: ReleaseTagView { + ReleaseTagView(label: Text("Tag.Active"), kind: .active) + } + static var latest: ReleaseTagView { ReleaseTagView(label: Text("Tag.Latest"), kind: .latest) } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 28a05dd0..9b373e87 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -155,6 +155,8 @@ private struct GroupedXcodeListContent: View { level: 0, versionCount: majorVersions.count, tag: majorVersions.contains(where: isLatestRelease) ? .latest : nil, + // The active Xcode is tagged on the deepest visible row: here only while collapsed + showsActiveTag: majorHasActiveXcode && !isMajorExpanded, appState: appState, onToggleExpanded: { var updatedExpandedMajorVersions = expandedMajorVersions @@ -194,6 +196,7 @@ private struct GroupedXcodeListContent: View { level: 1, versionCount: minorVersions.count, tag: groupTag(for: minorVersions), + showsActiveTag: minorHasActiveXcode && !isMinorExpanded, appState: appState, onToggleExpanded: { var updatedExpandedMinorVersions = expandedMinorVersions @@ -330,6 +333,7 @@ private struct XcodeVersionGroupRow: View { let level: Int let versionCount: Int let tag: ReleaseTagView? + let showsActiveTag: Bool let appState: AppState let onToggleExpanded: () -> Void @@ -355,6 +359,10 @@ private struct XcodeVersionGroupRow: View { tag } + if showsActiveTag { + ReleaseTagView.active + } + Text(verbatim: "\(versionCount)") .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(.secondary) @@ -364,10 +372,10 @@ private struct XcodeVersionGroupRow: View { .accessibilityHidden(true) } - if let latestRelease { - Text(verbatim: "Latest: \(latestRelease.description)") + if let subtitle { + Text(verbatim: subtitle) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -389,6 +397,15 @@ private struct XcodeVersionGroupRow: View { .contentShape(Rectangle()) } + /// "Latest: 27.0 · Active: 27.0 Beta 6" + private var subtitle: String? { + let parts = [ + latestRelease.map { "Latest: \($0.description)" }, + selectedVersion.map { "Active: \($0.description)" }, + ].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + @ViewBuilder private var icon: some View { if let icon = latestRelease?.icon { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 9e42152f..9ddf1782 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -65,6 +65,9 @@ struct XcodeListViewRow: View { if isLatestRelease { ReleaseTagView.latest.onSelection(selected) } + if xcode.selected { + ReleaseTagView.active.onSelection(selected) + } } if !xcode.identicalBuildsForCurrentVariant.isEmpty { diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index e1fac673..0ee2b09f 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -24309,6 +24309,17 @@ } } }, + "Tag.Active" : { + "comment" : "Tag on the Xcode that is currently selected with xcode-select", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + } + } + }, "Tag.Latest" : { "comment" : "Tag on the newest non-prerelease Xcode in the list", "localizations" : { From ce04a02104b20b1d1e2cf02bc2d9e10b4e80d52f Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 17:05:19 +0200 Subject: [PATCH 10/14] Use the two-parameter onChange(of:) in the progress ring Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/InstallationStepRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift index 3862a640..7b03acf7 100644 --- a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift +++ b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift @@ -85,7 +85,7 @@ private struct ProgressRing: View { .buttonStyle(.plain) .onHover { isHovering = $0 } .onAppear { isSpinning = fraction == nil } - .onChange(of: fraction == nil) { isSpinning = $0 } + .onChange(of: fraction == nil) { _, isIndeterminate in isSpinning = isIndeterminate } .accessibilityLabel(Text("StopInstallation")) } From d1caaddb728d4017d735c4fd382d4e15deb2d458 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 19:03:32 +0200 Subject: [PATCH 11/14] Draw a softer selection, anchor tree guides to titles, hide covered checkmarks - Selection: the sidebar's system highlight can't be restyled and made tags and secondary text hard to read, in and out of focus. Rows now draw their own highlight (accent color at 20% opacity) with regular text, tags keep their colors, and buttons keep their normal style. Arrow keys still move the selection through the visible versions. - Tree guides: branches meet the row's title line instead of the middle of the row, which sat below the title on two-line rows. - Group rows only show the active/select checkmark while collapsed; once expanded, the version rows below carry it. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/Common/TagView.swift | 15 +-- Xcodes/Frontend/XcodeList/XcodeListView.swift | 100 ++++++++++++++++-- .../Frontend/XcodeList/XcodeListViewRow.swift | 11 +- 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 8ecf6b4a..12fcdc43 100644 --- a/Xcodes/Frontend/Common/TagView.swift +++ b/Xcodes/Frontend/Common/TagView.swift @@ -47,26 +47,17 @@ struct ReleaseTagView: View { let label: Text let kind: Kind - /// On a selected row the tint would disappear into the highlight, so draw it in white instead - var isOnSelection = false var body: some View { - let color = isOnSelection ? Color.white : kind.color label .font(.caption2.weight(.semibold)) - .foregroundStyle(color) + .foregroundStyle(kind.color) .padding(.horizontal, 6) .padding(.vertical, 1) - .background(color.opacity(isOnSelection ? 0.22 : 0.16), in: Capsule()) - .overlay(Capsule().strokeBorder(color.opacity(isOnSelection ? 0.6 : 0.35), lineWidth: 0.5)) + .background(kind.color.opacity(0.16), in: Capsule()) + .overlay(Capsule().strokeBorder(kind.color.opacity(0.35), lineWidth: 0.5)) .fixedSize() } - - func onSelection(_ isOnSelection: Bool) -> ReleaseTagView { - var tag = self - tag.isOnSelection = isOnSelection - return tag - } } extension ReleaseTagView { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 9b373e87..aac6443e 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -11,6 +11,8 @@ struct XcodeListView: View { private let architecture: XcodeListArchitecture private let isInstalledOnly: Bool @AppStorage(PreferenceKey.allowedMajorVersions.rawValue) private var allowedMajorVersions = Int.max + @AppStorage(PreferenceKey.expandedMajorXcodeVersions.rawValue) private var expandedMajorVersionStorage = "" + @AppStorage(PreferenceKey.expandedMinorXcodeVersions.rawValue) private var expandedMinorVersionStorage = "" init(selectedXcodeID: Binding, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool, architecture: XcodeListArchitecture) { self._selectedXcodeID = selectedXcodeID @@ -37,8 +39,33 @@ struct XcodeListView: View { appState.allXcodes.latestReleaseForSelectedPrerelease(xcode) } + /// Version rows in display order, for moving the selection with the arrow keys + private var selectableXcodeIDs: [Xcode.ID] { + guard appState.enableGroupedXcodeList else { return visibleXcodes.map(\.xcode.id) } + let expandedMajors = Set(expandedMajorVersionStorage.split(separator: ",").compactMap { Int($0) }) + let expandedMinors = Set(expandedMinorVersionStorage.split(separator: ",").map(String.init)) + return visibleXcodes.groupedByMajorVersion(item: \.listItem) + .filter { expandedMajors.contains($0.majorVersion) } + .flatMap(\.minorVersionGroups) + .filter { expandedMinors.contains($0.id) } + .flatMap { $0.versions.map(\.xcode.id) } + } + + private func moveSelection(by offset: Int) -> KeyPress.Result { + let ids = selectableXcodeIDs + guard !ids.isEmpty else { return .ignored } + guard let current = selectedXcodeID, let index = ids.firstIndex(of: current) else { + selectedXcodeID = offset > 0 ? ids.first : ids.last + return .handled + } + selectedXcodeID = ids[min(max(index + offset, 0), ids.count - 1)] + return .handled + } + var body: some View { - List(selection: $selectedXcodeID) { + // Selection is drawn by the rows (SelectableRow) rather than List(selection:), because the sidebar's + // system highlight can't be restyled and left tags and secondary text hard to read. + List { if appState.enableGroupedXcodeList { GroupedXcodeListContent( xcodes: visibleXcodes, @@ -54,11 +81,15 @@ struct XcodeListView: View { appState: appState, latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) ) - .tag(entry.xcode.id) + .selectableRow(isSelected: selectedXcodeID == entry.xcode.id) { selectedXcodeID = entry.xcode.id } } } } .listStyle(.sidebar) + .focusable() + .focusEffectDisabled() + .onKeyPress(.downArrow) { moveSelection(by: 1) } + .onKeyPress(.upArrow) { moveSelection(by: -1) } .safeAreaInset(edge: .bottom, spacing: 0) { PlatformsPocket() .padding(.horizontal) @@ -175,7 +206,7 @@ private struct GroupedXcodeListContent: View { self.expandedMinorVersions = updatedExpandedMinorVersions } ) - .tag(majorVersions.first { $0.selected }?.id) + if isMajorExpanded { ForEach(Array(minorVersionGroups.enumerated()), id: \.element.id) { minorIndex, minorVersionGroup in @@ -210,12 +241,12 @@ private struct GroupedXcodeListContent: View { self.expandedMinorVersions = updatedExpandedMinorVersions } ) - .background(alignment: .leading) { + .treeGuides { TreeGuides(guides: [ TreeGuide(level: 0, extent: isLastMinor ? .elbow : .tee, isHighlighted: majorHasActiveXcode && (minorHasActiveXcode || !isLastMinor)) ]) } - .tag(minorVersions.first { $0.selected }?.id) + if isMinorExpanded { let entries = minorVersionGroup.versions @@ -231,13 +262,13 @@ private struct GroupedXcodeListContent: View { isLatestRelease: isLatestRelease(entry.xcode) ) .padding(.leading, TreeGuide.contentInset(forLevel: 2)) - .background(alignment: .leading) { + .treeGuides { TreeGuides(guides: [ TreeGuide(level: 0, extent: isLastMinor ? .none : .through, isHighlighted: majorHasActiveXcode && !isLastMinor), TreeGuide(level: 1, extent: isLastEntry ? .elbow : .tee, isHighlighted: minorHasActiveXcode, branchesToLeaf: true) ]) } - .tag(entry.xcode.id) + .selectableRow(isSelected: selectedXcodeID == entry.xcode.id) { selectedXcodeID = entry.xcode.id } } } } @@ -288,13 +319,15 @@ private struct TreeGuide: Hashable { private struct TreeGuides: View { let guides: [TreeGuide] + /// Where branches meet the row: the middle of its title line. Falls back to the row's middle. + var branchY: CGFloat? = nil var body: some View { Canvas { context, size in let lineWidth: CGFloat = 1.5 // Rows have a little vertical breathing room; extend past it so lines connect between rows. let overshoot: CGFloat = 4 - let midY = size.height / 2 + let midY = branchY ?? size.height / 2 for guide in guides { let color: Color = guide.isHighlighted ? .accentColor.opacity(0.75) : .secondary.opacity(0.35) var path = Path() @@ -354,6 +387,7 @@ private struct XcodeVersionGroupRow: View { HStack(spacing: 6) { Text(verbatim: displayName) .font(level == 0 ? .headline : .body.weight(.medium)) + .treeGuideTitle() if let tag { tag @@ -386,8 +420,11 @@ private struct XcodeVersionGroupRow: View { } .buttonStyle(.plain) - selectControl - .padding(.trailing, 16) + // Like the progress ring and Active tag, the checkmark belongs to the deepest visible row + if !isExpanded { + selectControl + .padding(.trailing, 16) + } installControl // Same column width as the Install/Open buttons, so the progress ring lines up with them .frame(minWidth: 67) @@ -596,3 +633,46 @@ struct XcodeListView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } + +// MARK: - Tree guide title anchor + +/// The vertical middle of a row's title, so tree guide branches meet the title rather than the row's middle. +private struct TreeGuideTitleMidYKey: SwiftUI.PreferenceKey { + static let defaultValue: CGFloat? = nil + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = value ?? nextValue() + } +} + +private let treeGuideRowSpace = "treeGuideRow" + +extension View { + /// Marks the row's title; tree guides drawn with `treeGuides(_:)` branch at its middle. + func treeGuideTitle() -> some View { + background { + GeometryReader { proxy in + Color.clear.preference(key: TreeGuideTitleMidYKey.self, value: proxy.frame(in: .named(treeGuideRowSpace)).midY) + } + } + } + + fileprivate func treeGuides(_ guides: @escaping () -> TreeGuides) -> some View { + coordinateSpace(name: treeGuideRowSpace) + .backgroundPreferenceValue(TreeGuideTitleMidYKey.self) { titleMidY in + let base = guides() + TreeGuides(guides: base.guides, branchY: titleMidY) + } + } + + /// Selects the row on click and draws a soft highlight that keeps text and tags legible. + func selectableRow(isSelected: Bool, onSelect: @escaping () -> Void) -> some View { + contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .listRowBackground( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.2) : .clear) + .padding(.horizontal, 10) + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 9ddf1782..642672e5 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -57,16 +57,17 @@ struct XcodeListViewRow: View { HStack { Text(verbatim: title) .font(.body) + .treeGuideTitle() if style == .grouped { if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { - prereleaseTag.onSelection(selected) + prereleaseTag } if isLatestRelease { - ReleaseTagView.latest.onSelection(selected) + ReleaseTagView.latest } if xcode.selected { - ReleaseTagView.active.onSelection(selected) + ReleaseTagView.active } } @@ -209,7 +210,7 @@ struct XcodeListViewRow: View { case .installed: Button("Open") { appState.open(xcode: xcode) } .textCase(.uppercase) - .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected)) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) .help("OpenDescription") case .notInstalled: InstallButton(xcode: xcode) @@ -218,7 +219,7 @@ struct XcodeListViewRow: View { case let .installing(installationStep): InstallationStepRowView( installationStep: installationStep, - highlighted: selected, + highlighted: false, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) case .uninstalling: From 1de8d1d1c02f884c147972e8bb656d1f994d1b1f Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 19:19:44 +0200 Subject: [PATCH 12/14] Line up group chevrons with their title On two-line group rows the chevron was centered on the whole row, below the title where the tree guide branch now ends. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index aac6443e..1769b8c9 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -373,7 +373,9 @@ private struct XcodeVersionGroupRow: View { var body: some View { HStack { Button(action: onToggleExpanded) { - HStack(spacing: 8) { + // Nested rows line the chevron up with the title, where the tree guide branch ends; + // top-level rows center it on the icon. + HStack(alignment: level == 0 ? .center : .firstTextBaseline, spacing: 8) { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) From a1a0d187f89111438e487e0299ffee580c63fbea Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 21:52:10 +0200 Subject: [PATCH 13/14] Show Open/Install only on the deepest visible row An expanded group repeated the Open or Install button of its latest release, so a branch could show three OPEN buttons for the same Xcode. Like the checkmark and progress ring, expanded groups now leave the button to the version rows below them. Co-Authored-By: Claude Opus 5.5 --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 1769b8c9..32dfb0e7 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -422,14 +422,15 @@ private struct XcodeVersionGroupRow: View { } .buttonStyle(.plain) - // Like the progress ring and Active tag, the checkmark belongs to the deepest visible row + // Like the progress ring and Active tag, the checkmark and Open/Install belong to the deepest + // visible row, so an expanded group leaves them to the rows below it if !isExpanded { selectControl .padding(.trailing, 16) + installControl + // Same column width as the Install/Open buttons, so the progress ring lines up with them + .frame(minWidth: 67) } - installControl - // Same column width as the Install/Open buttons, so the progress ring lines up with them - .frame(minWidth: 67) } .padding(.leading, CGFloat(level) * TreeGuide.levelIndent) .padding(.vertical, level == 0 ? 8 : 5) @@ -519,9 +520,7 @@ private struct XcodeVersionGroupRow: View { @ViewBuilder private var installControl: some View { - // Progress is shown once, on the deepest visible row: an expanded group leaves it to its children. - if !isExpanded, - let installingVersion, + if let installingVersion, case let .installing(installationStep) = installingVersion.installState { InstallationStepRowView( installationStep: installationStep, From cd79711983763a34822fc6d52b3728fd5accc1d7 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Tue, 29 Sep 2026 21:53:56 +0200 Subject: [PATCH 14/14] Never truncate the version in a row's title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several tags and the identical-builds and Apple silicon symbols, the title of a version row was squeezed to "2…". Keep the title at its full width and drop the small symbols first when the row is too narrow. Co-Authored-By: Claude Opus 5.5 --- .../Frontend/XcodeList/XcodeListViewRow.swift | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 642672e5..1a976274 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -54,39 +54,10 @@ struct XcodeListViewRow: View { } VStack(alignment: .leading) { - HStack { - Text(verbatim: title) - .font(.body) - .treeGuideTitle() - - if style == .grouped { - if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { - prereleaseTag - } - if isLatestRelease { - ReleaseTagView.latest - } - if xcode.selected { - ReleaseTagView.active - } - } - - if !xcode.identicalBuildsForCurrentVariant.isEmpty { - Image(systemName: "square.fill.on.square.fill") - .font(.subheadline) - .foregroundStyle(.secondary) - .accessibility(label: Text("IdenticalBuilds")) - .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) - .help("IdenticalBuilds.help") - } - - if xcode.architectures?.isAppleSilicon ?? false { - Image(systemName: "m4.button.horizontal") - .font(.subheadline) - .foregroundStyle(.secondary) - .accessibility(label: Text("Apple Silicon")) - .help("Apple Silicon") - } + // The version must never truncate; when space is tight, drop the small symbols first + ViewThatFits(in: .horizontal) { + titleLine(showsSymbols: true) + titleLine(showsSymbols: false) } if let caption { @@ -140,6 +111,46 @@ struct XcodeListViewRow: View { } } + private func titleLine(showsSymbols: Bool) -> some View { + HStack { + Text(verbatim: title) + .font(.body) + .fixedSize() + .treeGuideTitle() + + if style == .grouped { + if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { + prereleaseTag + } + if isLatestRelease { + ReleaseTagView.latest + } + if xcode.selected { + ReleaseTagView.active + } + } + + if showsSymbols { + if !xcode.identicalBuildsForCurrentVariant.isEmpty { + Image(systemName: "square.fill.on.square.fill") + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibility(label: Text("IdenticalBuilds")) + .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) + .help("IdenticalBuilds.help") + } + + if xcode.architectures?.isAppleSilicon ?? false { + Image(systemName: "m4.button.horizontal") + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibility(label: Text("Apple Silicon")) + .help("Apple Silicon") + } + } + } + } + @ViewBuilder func appIconView(for xcode: Xcode) -> some View { if let icon = xcode.icon {