diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 9d97614e..12fcdc43 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,66 @@ 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 + case active + + var color: Color { + switch self { + case .beta: return .orange + case .releaseCandidate: return .purple + case .otherPrerelease: return .teal + case .latest: return .green + case .active: return .blue + } + } + } + + 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 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/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/InstallationStepRowView.swift b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift index 1d605fa0..7b03acf7 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) { _, isIndeterminate in isSpinning = isIndeterminate } + .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 12cfdb05..32dfb0e7 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) @@ -116,22 +147,47 @@ 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) 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: majorVersionGroup.displayName, + displayName: "Xcode \(majorVersionGroup.displayName)", latestRelease: latestMajorRelease, latestSelectableRelease: latestMajorRelease, latestSelectionTarget: latestInstalledMajorVersion, selectedVersion: majorVersions.first { $0.selected }, installingVersion: majorVersions.first { $0.installState.installing }, isExpanded: isMajorExpanded, - indentation: 0, + 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 @@ -150,13 +206,15 @@ private struct GroupedXcodeListContent: View { self.expandedMinorVersions = updatedExpandedMinorVersions } ) - .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 +224,10 @@ private struct GroupedXcodeListContent: View { selectedVersion: minorVersions.first { $0.selected }, installingVersion: minorVersions.first { $0.installState.installing }, isExpanded: isMinorExpanded, - indentation: 20, + level: 1, + versionCount: minorVersions.count, + tag: groupTag(for: minorVersions), + showsActiveTag: minorHasActiveXcode && !isMinorExpanded, appState: appState, onToggleExpanded: { var updatedExpandedMinorVersions = expandedMinorVersions @@ -180,18 +241,34 @@ private struct GroupedXcodeListContent: View { self.expandedMinorVersions = updatedExpandedMinorVersions } ) - .tag(minorVersions.first { $0.selected }?.id) + .treeGuides { + TreeGuides(guides: [ + TreeGuide(level: 0, extent: isLastMinor ? .elbow : .tee, isHighlighted: majorHasActiveXcode && (minorHasActiveXcode || !isLastMinor)) + ]) + } + 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, appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode), + style: .grouped, + isLatestRelease: isLatestRelease(entry.xcode) ) - .padding(.leading, 40) - .tag(entry.xcode.id) + .padding(.leading, TreeGuide.contentInset(forLevel: 2)) + .treeGuides { + TreeGuides(guides: [ + TreeGuide(level: 0, extent: isLastMinor ? .none : .through, isHighlighted: majorHasActiveXcode && !isLastMinor), + TreeGuide(level: 1, extent: isLastEntry ? .elbow : .tee, isHighlighted: minorHasActiveXcode, branchesToLeaf: true) + ]) + } + .selectableRow(isSelected: selectedXcodeID == entry.xcode.id) { selectedXcodeID = entry.xcode.id } } } } @@ -200,6 +277,84 @@ 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] + /// 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 = branchY ?? 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? @@ -208,29 +363,55 @@ private struct XcodeVersionGroupRow: View { let selectedVersion: Xcode? let installingVersion: Xcode? let isExpanded: Bool - let indentation: CGFloat + let level: Int + let versionCount: Int + let tag: ReleaseTagView? + let showsActiveTag: Bool let appState: AppState let onToggleExpanded: () -> Void 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) .frame(width: 12, height: 12) - icon + if level == 0 { + icon + } VStack(alignment: .leading, spacing: 2) { - Text(verbatim: "Xcode \(displayName)") - .font(.body.weight(indentation == 0 ? .medium : .regular)) + HStack(spacing: 6) { + Text(verbatim: displayName) + .font(level == 0 ? .headline : .body.weight(.medium)) + .treeGuideTitle() + + if let tag { + tag + } + + if showsActiveTag { + ReleaseTagView.active + } + + 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)") + if let subtitle { + Text(verbatim: subtitle) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -241,15 +422,30 @@ private struct XcodeVersionGroupRow: View { } .buttonStyle(.plain) - selectControl - .padding(.trailing, 16) - installControl + // 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) + } } - .padding(.leading, indentation) - .padding(.vertical, indentation == 0 ? 8 : 6) + .padding(.leading, CGFloat(level) * TreeGuide.levelIndent) + .padding(.vertical, level == 0 ? 8 : 5) .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 { @@ -260,7 +456,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) } } @@ -294,8 +490,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") @@ -437,3 +634,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 2d86e634..1a976274 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -4,49 +4,67 @@ import Version import XcodesKit struct XcodeListViewRow: View { + enum Style { + /// "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 + } + let xcode: Xcode let selected: Bool let appState: AppState let latestReleaseForSelectedPrerelease: Xcode? + let style: Style + let isLatestRelease: Bool - init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil) { + 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 { + switch style { + case .flat: + 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 and the install path, when present. + private var caption: String? { + var parts: [String] = 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 { 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 { - Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.body) - - if !xcode.identicalBuildsForCurrentVariant.isEmpty { - Image(systemName: "square.fill.on.square.fill") - .font(.subheadline) - .foregroundColor(.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) - .foregroundColor(.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 case let .installed(path) = xcode.installState { - Text(verbatim: path.string) + if let caption { + Text(verbatim: caption) .font(.caption) - .foregroundColor(.secondary) + // A hierarchical style stays legible on the selection highlight, unlike Color.secondary + .foregroundStyle(.secondary) } } @@ -55,6 +73,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 { @@ -91,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 { @@ -101,7 +161,7 @@ struct XcodeListViewRow: View { Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode") .resizable() .frame(width: 32, height: 32) - .opacity(0.2) + .opacity(0.5) } } @@ -130,8 +190,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") @@ -160,7 +221,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) @@ -169,7 +230,7 @@ struct XcodeListViewRow: View { case let .installing(installationStep): InstallationStepRowView( installationStep: installationStep, - highlighted: selected, + highlighted: false, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) case .uninstalling: diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..0ee2b09f 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -24309,6 +24309,28 @@ } } }, + "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" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest" + } + } + } + }, "TerminateAfterLastWindowClosed" : { "localizations" : { "ar" : {