|
|
@@ -2,12 +2,20 @@ import SwiftUI
|
|
|
import AppKit
|
|
|
|
|
|
struct LauncherRootView: View {
|
|
|
+ enum LayoutMode {
|
|
|
+ case grid
|
|
|
+ case panel
|
|
|
+ }
|
|
|
+
|
|
|
@State private var query = ""
|
|
|
@State private var selectedApp: LauncherApp?
|
|
|
@State private var showingPremiumScreen = false
|
|
|
+ @State private var layoutMode: LayoutMode = .grid
|
|
|
|
|
|
private let apps = LauncherApp.sampleApps
|
|
|
- private let columns = [GridItem(.adaptive(minimum: 120, maximum: 145), spacing: 22)]
|
|
|
+ private var columns: [GridItem] {
|
|
|
+ [GridItem(.adaptive(minimum: 120, maximum: 145), spacing: 22)]
|
|
|
+ }
|
|
|
|
|
|
private var filteredApps: [LauncherApp] {
|
|
|
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
@@ -24,7 +32,21 @@ struct LauncherRootView: View {
|
|
|
.ignoresSafeArea()
|
|
|
|
|
|
VStack(spacing: 16) {
|
|
|
- SearchHeader(query: $query)
|
|
|
+ SearchHeader(
|
|
|
+ query: $query,
|
|
|
+ isPanelMode: layoutMode == .panel,
|
|
|
+ onToggleLayout: {
|
|
|
+ layoutMode = layoutMode == .grid ? .panel : .grid
|
|
|
+ },
|
|
|
+ onOpenPremium: {
|
|
|
+ showingPremiumScreen = true
|
|
|
+ },
|
|
|
+ onOpenGoogle: {
|
|
|
+ if let url = URL(string: "https://www.google.com") {
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
.padding(.top, 12)
|
|
|
|
|
|
PromoBanner(
|
|
|
@@ -41,16 +63,22 @@ struct LauncherRootView: View {
|
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
} else {
|
|
|
- ScrollView {
|
|
|
- LazyVGrid(columns: columns, spacing: 20) {
|
|
|
- ForEach(filteredApps) { app in
|
|
|
- AppTileView(app: app) {
|
|
|
- handleAppTap(app)
|
|
|
+ if layoutMode == .panel {
|
|
|
+ IconPanelView(apps: filteredApps) { app in
|
|
|
+ handleAppTap(app)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ScrollView {
|
|
|
+ LazyVGrid(columns: columns, spacing: 20) {
|
|
|
+ ForEach(filteredApps) { app in
|
|
|
+ AppTileView(app: app) {
|
|
|
+ handleAppTap(app)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.bottom, 18)
|
|
|
}
|
|
|
- .padding(.horizontal, 14)
|
|
|
- .padding(.bottom, 18)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -79,6 +107,10 @@ struct LauncherRootView: View {
|
|
|
|
|
|
private struct SearchHeader: View {
|
|
|
@Binding var query: String
|
|
|
+ let isPanelMode: Bool
|
|
|
+ let onToggleLayout: () -> Void
|
|
|
+ let onOpenPremium: () -> Void
|
|
|
+ let onOpenGoogle: () -> Void
|
|
|
|
|
|
var body: some View {
|
|
|
HStack {
|
|
|
@@ -103,8 +135,25 @@ private struct SearchHeader: View {
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
- Image(systemName: "list.bullet")
|
|
|
- Image(systemName: "ellipsis")
|
|
|
+ Button(action: onToggleLayout) {
|
|
|
+ Image(systemName: isPanelMode ? "square.grid.2x2" : "list.bullet")
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+
|
|
|
+ Menu {
|
|
|
+ Button("Clear Search") {
|
|
|
+ query = ""
|
|
|
+ }
|
|
|
+ Button("Open Premium") {
|
|
|
+ onOpenPremium()
|
|
|
+ }
|
|
|
+ Button("Open Google") {
|
|
|
+ onOpenGoogle()
|
|
|
+ }
|
|
|
+ } label: {
|
|
|
+ Image(systemName: "ellipsis")
|
|
|
+ }
|
|
|
+ .menuStyle(.borderlessButton)
|
|
|
}
|
|
|
.font(.system(size: 15, weight: .semibold))
|
|
|
.foregroundStyle(.white.opacity(0.82))
|
|
|
@@ -113,6 +162,99 @@ private struct SearchHeader: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+private struct IconPanelView: View {
|
|
|
+ let apps: [LauncherApp]
|
|
|
+ let onAppTap: (LauncherApp) -> Void
|
|
|
+
|
|
|
+ private let actions = [
|
|
|
+ "house", "magnifyingglass", "target", "map", "heart", "person.2", "clock",
|
|
|
+ "star", "exclamationmark.circle", "trash", "paperplane", "doc", "lock",
|
|
|
+ ]
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ ScrollView {
|
|
|
+ HStack(alignment: .top, spacing: 20) {
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ ForEach(Array(apps.prefix(10))) { app in
|
|
|
+ Button(action: { onAppTap(app) }) {
|
|
|
+ HStack(spacing: 14) {
|
|
|
+ AppIconGlyph(app: app)
|
|
|
+ Text(app.name)
|
|
|
+ .font(.system(size: 18, weight: .medium))
|
|
|
+ .foregroundStyle(.white.opacity(0.94))
|
|
|
+ .lineLimit(1)
|
|
|
+ .truncationMode(.tail)
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(.vertical, 3)
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .frame(width: 320, alignment: .topLeading)
|
|
|
+
|
|
|
+ VStack(alignment: .trailing, spacing: 14) {
|
|
|
+ ForEach(Array(apps.prefix(10).enumerated()), id: \.offset) { index, _ in
|
|
|
+ HStack(spacing: 12) {
|
|
|
+ ForEach(0..<8, id: \.self) { col in
|
|
|
+ let symbol = actions[(index * 3 + col) % actions.count]
|
|
|
+ ActionBubble(symbol: symbol, highlighted: col == 0 && index % 3 == 0)
|
|
|
+ }
|
|
|
+ Image(systemName: "info.circle")
|
|
|
+ .foregroundStyle(.white.opacity(0.6))
|
|
|
+ .font(.system(size: 17))
|
|
|
+ .frame(width: 20)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .topTrailing)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.bottom, 18)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct AppIconGlyph: View {
|
|
|
+ let app: LauncherApp
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ ZStack {
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
+ .fill(Color.white.opacity(0.08))
|
|
|
+ .frame(width: 48, height: 48)
|
|
|
+
|
|
|
+ if let icon = NSImage(named: app.assetIconName) {
|
|
|
+ Image(nsImage: icon)
|
|
|
+ .resizable()
|
|
|
+ .scaledToFit()
|
|
|
+ .frame(width: 32, height: 32)
|
|
|
+ } else {
|
|
|
+ Image(systemName: app.fallbackSymbolName)
|
|
|
+ .font(.system(size: 20, weight: .semibold))
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct ActionBubble: View {
|
|
|
+ let symbol: String
|
|
|
+ let highlighted: Bool
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Circle()
|
|
|
+ .fill(highlighted ? Color.cyan.opacity(0.22) : Color.white.opacity(0.08))
|
|
|
+ .overlay(
|
|
|
+ Image(systemName: symbol)
|
|
|
+ .foregroundStyle(.white.opacity(0.9))
|
|
|
+ .font(.system(size: 15, weight: .medium))
|
|
|
+ )
|
|
|
+ .frame(width: 42, height: 42)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
private struct PromoBanner: View {
|
|
|
let onCloseTap: () -> Void
|
|
|
let onUpgradeTap: () -> Void
|