Skip to content

Commit c4b1b07

Browse files
yibieclaude
andcommitted
feat: flow-wrap category chips in Discover, add FlowLayout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f72f7aa commit c4b1b07

3 files changed

Lines changed: 97 additions & 14 deletions

File tree

SkillsManager.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
7970AB614D3D0C641C9FB0EC /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF0465C82F147C7332064AC /* SidebarView.swift */; };
2727
79C92DE48CAF4F5BBAEDF299 /* InstallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4AC87ED906F4D954E16850 /* InstallService.swift */; };
2828
7A8064994B4633978FA3E334 /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BA7574CDF65767B64E1FEE /* DiscoverView.swift */; };
29+
BB9876543210FEDCBA987654 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1234567890ABCDEF123456 /* FlowLayout.swift */; };
2930
7C0C9E9A97FA6E85F1819B7B /* ProjectScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C4A4957C14CE0EEBB00D79 /* ProjectScanner.swift */; };
3031
7E6931289A067C712BB9B78B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7DBBBF8B70FCF8E665BA164F /* Assets.xcassets */; };
3132
85E646533EC754C4C02414FE /* ClaudeCodeAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE38B49272B1AA0A1752A87 /* ClaudeCodeAdapter.swift */; };
@@ -50,6 +51,7 @@
5051
34850DA72E29812DF60C423C /* SkillsManager/Services/SymlinkInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillsManager/Services/SymlinkInstaller.swift; sourceTree = SOURCE_ROOT; };
5152
3BE38B49272B1AA0A1752A87 /* ClaudeCodeAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeAdapter.swift; sourceTree = "<group>"; };
5253
42BA7574CDF65767B64E1FEE /* DiscoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverView.swift; sourceTree = "<group>"; };
54+
AA1234567890ABCDEF123456 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = "<group>"; };
5355
50069145DEE4F107B789596A /* VersionHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionHistoryView.swift; sourceTree = "<group>"; };
5456
522C4FF85D5865CEB9DEEF64 /* LLMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMService.swift; sourceTree = "<group>"; };
5557
5274AE0EE82A46941B29B3F1 /* SandboxSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxSlot.swift; sourceTree = "<group>"; };
@@ -98,6 +100,7 @@
98100
children = (
99101
2F161109A29CF7DCE1257393 /* ContentView.swift */,
100102
42BA7574CDF65767B64E1FEE /* DiscoverView.swift */,
103+
AA1234567890ABCDEF123456 /* FlowLayout.swift */,
101104
F000347C95927A1B1EE9704F /* ProjectSkillsView.swift */,
102105
F6BD0C5D68F6CEB4FE4C14CD /* SandboxView.swift */,
103106
8B204889413D1B5B1DE273D2 /* SettingsView.swift */,
@@ -274,6 +277,7 @@
274277
617909719D9F4BFBB8B9BCDD /* UniversalAdapter.swift in Sources */,
275278
5427BC541444486E97ABECF1 /* ContentView.swift in Sources */,
276279
7A8064994B4633978FA3E334 /* DiscoverView.swift in Sources */,
280+
BB9876543210FEDCBA987654 /* FlowLayout.swift in Sources */,
277281
67760A8B31C1AA2B7744DF2D /* FileWatcher.swift in Sources */,
278282
0947139B78E3F9D9857F730A /* GitService.swift in Sources */,
279283
79C92DE48CAF4F5BBAEDF299 /* InstallService.swift in Sources */,

SkillsManager/Views/DiscoverView.swift

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ struct DiscoverView: View {
1212
@State private var searchText = ""
1313
@State private var selectedCategory: String?
1414

15+
/// Max chips shown inline; the rest go into the "More" menu.
16+
private static let maxVisible = 8
17+
1518
private var filtered: [MarketplacePlugin] {
1619
plugins.filter { plugin in
1720
let matchesSearch = searchText.isEmpty
@@ -23,29 +26,56 @@ struct DiscoverView: View {
2326
}
2427
}
2528

26-
private var categories: [String] {
27-
Array(Set(plugins.compactMap { $0.category })).sorted()
29+
/// All categories sorted by frequency descending, then alphabetically.
30+
private var rankedCategories: [String] {
31+
var freq: [String: Int] = [:]
32+
for p in plugins { if let c = p.category { freq[c, default: 0] += 1 } }
33+
return freq.keys.sorted { freq[$0]! != freq[$1]! ? freq[$0]! > freq[$1]! : $0 < $1 }
2834
}
2935

36+
private var visibleCategories: [String] { Array(rankedCategories.prefix(Self.maxVisible)) }
37+
private var overflowCategories: [String] { Array(rankedCategories.dropFirst(Self.maxVisible)) }
38+
3039
var body: some View {
3140
VStack(spacing: 0) {
32-
if !categories.isEmpty {
33-
ScrollView(.horizontal, showsIndicators: false) {
34-
HStack(spacing: 8) {
35-
CategoryChip(label: "All", isSelected: selectedCategory == nil) {
36-
selectedCategory = nil
41+
if !rankedCategories.isEmpty {
42+
FlowLayout(hSpacing: 8, vSpacing: 6) {
43+
CategoryChip(label: "All", isSelected: selectedCategory == nil) {
44+
selectedCategory = nil
45+
}
46+
ForEach(visibleCategories, id: \.self) { cat in
47+
CategoryChip(label: cat.capitalized, isSelected: selectedCategory == cat) {
48+
selectedCategory = cat
3749
}
38-
ForEach(categories, id: \.self) { cat in
39-
CategoryChip(label: cat.capitalized, isSelected: selectedCategory == cat) {
40-
selectedCategory = cat
50+
}
51+
if !overflowCategories.isEmpty {
52+
let isMoreSelected = selectedCategory.map { overflowCategories.contains($0) } ?? false
53+
Menu {
54+
Button("All Categories") { selectedCategory = nil }
55+
Divider()
56+
ForEach(overflowCategories, id: \.self) { cat in
57+
Button(cat.capitalized) { selectedCategory = cat }
58+
}
59+
} label: {
60+
HStack(spacing: 4) {
61+
Text(isMoreSelected ? selectedCategory!.capitalized : "More")
62+
.font(.caption)
63+
.lineLimit(1)
64+
Image(systemName: "chevron.down")
65+
.font(.caption2)
4166
}
67+
.padding(.horizontal, 10)
68+
.padding(.vertical, 5)
69+
.background(isMoreSelected ? Color.accentColor : Color.secondary.opacity(0.1), in: Capsule())
70+
.foregroundStyle(isMoreSelected ? Color.white : Color.primary)
4271
}
72+
.menuStyle(.borderlessButton)
73+
.menuIndicator(.hidden)
74+
.fixedSize()
4375
}
44-
.padding(.horizontal, 16)
45-
.fixedSize(horizontal: false, vertical: true)
4676
}
47-
.frame(maxWidth: .infinity)
48-
.padding(.vertical, 8)
77+
.padding(.horizontal, 16)
78+
.padding(.vertical, 10)
4979
Divider()
5080
}
5181

@@ -154,6 +184,8 @@ private struct CategoryChip: View {
154184
Button(action: action) {
155185
Text(label)
156186
.font(.caption)
187+
.lineLimit(1)
188+
.fixedSize()
157189
.padding(.horizontal, 10)
158190
.padding(.vertical, 5)
159191
.background(
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import SwiftUI
2+
3+
/// A layout that wraps children into multiple rows like CSS `flex-wrap`.
4+
struct FlowLayout: Layout {
5+
var hSpacing: CGFloat = 8
6+
var vSpacing: CGFloat = 8
7+
8+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
9+
let rows = computeRows(proposal: proposal, subviews: subviews)
10+
let height = rows.map { $0.map { $0.size.height }.max() ?? 0 }.reduce(0) { $0 + $1 }
11+
+ CGFloat(max(rows.count - 1, 0)) * vSpacing
12+
return CGSize(width: proposal.width ?? 0, height: height)
13+
}
14+
15+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
16+
let rows = computeRows(proposal: proposal, subviews: subviews)
17+
var y = bounds.minY
18+
for row in rows {
19+
let rowHeight = row.map { $0.size.height }.max() ?? 0
20+
var x = bounds.minX
21+
for item in row {
22+
item.view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(item.size))
23+
x += item.size.width + hSpacing
24+
}
25+
y += rowHeight + vSpacing
26+
}
27+
}
28+
29+
private struct Item { let view: LayoutSubview; let size: CGSize }
30+
31+
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[Item]] {
32+
let maxWidth = proposal.width ?? .infinity
33+
var rows: [[Item]] = [[]]
34+
var rowWidth: CGFloat = 0
35+
36+
for view in subviews {
37+
let size = view.sizeThatFits(.unspecified)
38+
if rowWidth + size.width > maxWidth, !rows[rows.endIndex - 1].isEmpty {
39+
rows.append([])
40+
rowWidth = 0
41+
}
42+
rows[rows.endIndex - 1].append(Item(view: view, size: size))
43+
rowWidth += size.width + hSpacing
44+
}
45+
return rows
46+
}
47+
}

0 commit comments

Comments
 (0)