|
|
@@ -0,0 +1,310 @@
|
|
|
+import SwiftUI
|
|
|
+import AppKit
|
|
|
+
|
|
|
+extension Notification.Name {
|
|
|
+ static let openWidgetsPage = Notification.Name("openWidgetsPage")
|
|
|
+}
|
|
|
+
|
|
|
+struct WidgetsRootView: View {
|
|
|
+ let apps: [LauncherApp]
|
|
|
+ @Binding var selectedAppID: UUID?
|
|
|
+ @Binding var widgetLibraryIDsData: String
|
|
|
+
|
|
|
+ @State private var query = ""
|
|
|
+ @State private var selectedSize: WidgetSize = .small
|
|
|
+
|
|
|
+ private var filteredApps: [LauncherApp] {
|
|
|
+ let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard !q.isEmpty else { return apps }
|
|
|
+ return apps.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var selectedApp: LauncherApp? {
|
|
|
+ guard let selectedAppID else { return nil }
|
|
|
+ return apps.first { $0.id == selectedAppID }
|
|
|
+ }
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ HStack(spacing: 14) {
|
|
|
+ sidebar
|
|
|
+ .frame(width: 260)
|
|
|
+
|
|
|
+ Divider()
|
|
|
+ .overlay(Color.white.opacity(0.08))
|
|
|
+
|
|
|
+ mainContent
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.bottom, 12)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var sidebar: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ Text("All Widgets")
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
+ .padding(.top, 6)
|
|
|
+
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
+ .foregroundStyle(.white.opacity(0.7))
|
|
|
+ TextField("Search widgets", text: $query)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .foregroundStyle(.white.opacity(0.94))
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 9)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
+ .fill(Color.black.opacity(0.22))
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(0.08), lineWidth: 1)
|
|
|
+ )
|
|
|
+
|
|
|
+ ScrollView {
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ ForEach(filteredApps) { app in
|
|
|
+ WidgetSidebarRow(
|
|
|
+ app: app,
|
|
|
+ isSelected: app.id == selectedAppID,
|
|
|
+ onSelect: {
|
|
|
+ selectedAppID = app.id
|
|
|
+ ensureInWidgetLibrary(appID: app.id)
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
+ .fill(Color.white.opacity(0.03))
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(0.06), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private var mainContent: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 14) {
|
|
|
+ if let selectedApp {
|
|
|
+ header(for: selectedApp)
|
|
|
+
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Text("Widget Size")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(.white.opacity(0.8))
|
|
|
+ Spacer()
|
|
|
+ WidgetSizePicker(selected: $selectedSize)
|
|
|
+ }
|
|
|
+ .padding(.top, 4)
|
|
|
+
|
|
|
+ HStack(alignment: .top, spacing: 14) {
|
|
|
+ WidgetPreviewCard(app: selectedApp, size: selectedSize)
|
|
|
+ .frame(maxWidth: 560)
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Button(action: {
|
|
|
+ addToDesktop(app: selectedApp, size: selectedSize)
|
|
|
+ }) {
|
|
|
+ Text("Add to Desktop")
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
+ .fill(Color.blue.opacity(0.9))
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+
|
|
|
+ Button(action: {
|
|
|
+ DesktopWidgetWindowManager.shared.removeAll(forAppID: selectedApp.id)
|
|
|
+ }) {
|
|
|
+ Text("Remove")
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
+ .foregroundStyle(.white.opacity(0.9))
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
+ .fill(Color.white.opacity(0.08))
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+ }
|
|
|
+ .padding(.top, 2)
|
|
|
+ } else {
|
|
|
+ emptyState
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
+ .fill(Color.white.opacity(0.02))
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(0.06), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func header(for app: LauncherApp) -> some View {
|
|
|
+ HStack(spacing: 12) {
|
|
|
+ AppIconView(app: app, size: 42, showAppBackground: true, iconPaddingFactor: 0.1)
|
|
|
+ .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 6)
|
|
|
+ VStack(alignment: .leading, spacing: 3) {
|
|
|
+ Text(app.name)
|
|
|
+ .font(.system(size: 20, weight: .bold))
|
|
|
+ .foregroundStyle(.white.opacity(0.94))
|
|
|
+ Text("Choose a widget size and add it to your desktop.")
|
|
|
+ .font(.system(size: 12.5, weight: .medium))
|
|
|
+ .foregroundStyle(.white.opacity(0.65))
|
|
|
+ }
|
|
|
+ Spacer()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var emptyState: some View {
|
|
|
+ VStack(spacing: 10) {
|
|
|
+ Image(systemName: "widget.small")
|
|
|
+ .font(.system(size: 30, weight: .semibold))
|
|
|
+ .foregroundStyle(.white.opacity(0.75))
|
|
|
+ Text("Select an app to preview widgets")
|
|
|
+ .font(.title3.weight(.semibold))
|
|
|
+ .foregroundStyle(.white.opacity(0.9))
|
|
|
+ Text("Pick any app from the left to customize its widget.")
|
|
|
+ .font(.subheadline)
|
|
|
+ .foregroundStyle(.white.opacity(0.7))
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func ensureInWidgetLibrary(appID: UUID) {
|
|
|
+ var updated = decodeUUIDSet(from: widgetLibraryIDsData)
|
|
|
+ updated.insert(appID)
|
|
|
+ widgetLibraryIDsData = encodeUUIDSet(updated)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func addToDesktop(app: LauncherApp, size: WidgetSize) {
|
|
|
+ ensureInWidgetLibrary(appID: app.id)
|
|
|
+ let instance = WidgetInstance(appID: app.id, size: size, origin: nil)
|
|
|
+ DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
|
|
|
+ apps.first { $0.id == id }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct WidgetSidebarRow: View {
|
|
|
+ let app: LauncherApp
|
|
|
+ let isSelected: Bool
|
|
|
+ let onSelect: () -> Void
|
|
|
+
|
|
|
+ @State private var hovering = false
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Button(action: onSelect) {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ AppIconView(app: app, size: 30, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
+ Text(app.name)
|
|
|
+ .font(.system(size: 13.5, weight: .semibold))
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
+ .lineLimit(1)
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 8)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
+ .fill(backgroundFill)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(isSelected ? 0.18 : 0.06), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .onHover { hovering = $0 }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var backgroundFill: Color {
|
|
|
+ if isSelected { return Color.blue.opacity(0.18) }
|
|
|
+ if hovering { return Color.white.opacity(0.06) }
|
|
|
+ return Color.white.opacity(0.02)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct WidgetSizePicker: View {
|
|
|
+ @Binding var selected: WidgetSize
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ ForEach(WidgetSize.allCases, id: \.self) { size in
|
|
|
+ Button(action: { selected = size }) {
|
|
|
+ Text(size.title)
|
|
|
+ .font(.system(size: 12, weight: .bold))
|
|
|
+ .foregroundStyle(.white.opacity(selected == size ? 0.95 : 0.75))
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ .background(
|
|
|
+ Capsule(style: .continuous)
|
|
|
+ .fill(selected == size ? Color.white.opacity(0.14) : Color.white.opacity(0.06))
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ Capsule(style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(selected == size ? 0.16 : 0.08), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
|
|
|
+ guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
|
|
|
+ do {
|
|
|
+ let strings = try JSONDecoder().decode([String].self, from: data)
|
|
|
+ return Set(strings.compactMap(UUID.init(uuidString:)))
|
|
|
+ } catch {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private func encodeUUIDSet(_ set: Set<UUID>) -> String {
|
|
|
+ do {
|
|
|
+ let strings = set.map(\.uuidString).sorted()
|
|
|
+ let data = try JSONEncoder().encode(strings)
|
|
|
+ return String(decoding: data, as: UTF8.self)
|
|
|
+ } catch {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#Preview {
|
|
|
+ ZStack {
|
|
|
+ Color.black
|
|
|
+ WidgetsRootView(
|
|
|
+ apps: LauncherApp.sampleApps,
|
|
|
+ selectedAppID: .constant(LauncherApp.sampleApps.first?.id),
|
|
|
+ widgetLibraryIDsData: .constant("")
|
|
|
+ )
|
|
|
+ .frame(width: 980, height: 650)
|
|
|
+ .padding()
|
|
|
+ }
|
|
|
+}
|