|
@@ -416,97 +416,134 @@ private struct IconPanelView: View {
|
|
|
@Binding var draggedAppID: UUID?
|
|
@Binding var draggedAppID: UUID?
|
|
|
@Binding var lastDropDestinationAppID: UUID?
|
|
@Binding var lastDropDestinationAppID: UUID?
|
|
|
let onMove: (UUID, UUID) -> Void
|
|
let onMove: (UUID, UUID) -> Void
|
|
|
- @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
|
|
|
|
|
-
|
|
|
|
|
- private let actions = [
|
|
|
|
|
- "house", "magnifyingglass", "target", "map", "heart", "person.2", "clock",
|
|
|
|
|
- "star", "exclamationmark.circle", "trash", "paperplane", "doc", "lock",
|
|
|
|
|
- ]
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
ScrollView {
|
|
ScrollView {
|
|
|
- HStack(alignment: .top, spacing: 20) {
|
|
|
|
|
- VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
- ForEach(apps) { app in
|
|
|
|
|
- HStack(spacing: 14) {
|
|
|
|
|
- // Tap-to-open area (kept separate from the pin menu so
|
|
|
|
|
- // clicking "Pin/Unpin" doesn't also trigger open).
|
|
|
|
|
- HStack(spacing: 14) {
|
|
|
|
|
- AppIconGlyph(app: app)
|
|
|
|
|
- Text(app.name)
|
|
|
|
|
- .font(.system(size: 18, weight: .medium))
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.94))
|
|
|
|
|
- .lineLimit(1)
|
|
|
|
|
- .truncationMode(.tail)
|
|
|
|
|
- }
|
|
|
|
|
- .contentShape(Rectangle())
|
|
|
|
|
- .onTapGesture { onAppTap(app) }
|
|
|
|
|
-
|
|
|
|
|
- Spacer(minLength: 0)
|
|
|
|
|
- }
|
|
|
|
|
- .padding(.vertical, 3)
|
|
|
|
|
- .onDrag {
|
|
|
|
|
- // Used by the reorder drop delegate.
|
|
|
|
|
- draggedAppID = app.id
|
|
|
|
|
- lastDropDestinationAppID = nil
|
|
|
|
|
- return NSItemProvider(object: app.id.uuidString as NSString)
|
|
|
|
|
- }
|
|
|
|
|
- .onDrop(
|
|
|
|
|
- of: [UTType.text],
|
|
|
|
|
- delegate: TileReorderDropDelegate(
|
|
|
|
|
- destinationAppID: app.id,
|
|
|
|
|
- draggedAppID: $draggedAppID,
|
|
|
|
|
- lastDropDestinationAppID: $lastDropDestinationAppID,
|
|
|
|
|
- onMove: onMove
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- Spacer(minLength: 0)
|
|
|
|
|
- }
|
|
|
|
|
- .frame(width: 320, alignment: .topLeading)
|
|
|
|
|
-
|
|
|
|
|
- VStack(alignment: .trailing, spacing: 14) {
|
|
|
|
|
- ForEach(Array(apps.enumerated()), id: \.element.id) { index, _ in
|
|
|
|
|
- HStack(spacing: 10) {
|
|
|
|
|
- // Reduce the number of action icons and add a working pin/unpin button.
|
|
|
|
|
- // Layout: [pin/unpin] + 6 action bubbles
|
|
|
|
|
- ForEach(0..<7, id: \.self) { col in
|
|
|
|
|
- let app = apps[index]
|
|
|
|
|
- if col == 0 {
|
|
|
|
|
- let isPinned = pinnedIDs.contains(app.id)
|
|
|
|
|
- Button(action: { togglePin(app) }) {
|
|
|
|
|
- ActionBubble(
|
|
|
|
|
- symbol: isPinned ? "pin.fill" : "pin",
|
|
|
|
|
- highlighted: isPinned
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- .buttonStyle(.plain)
|
|
|
|
|
- } else {
|
|
|
|
|
- let actionCol = col - 1
|
|
|
|
|
- let symbol = actions[(index * 3 + actionCol) % actions.count]
|
|
|
|
|
- let highlighted = actionCol == 0 && index % 3 == 0
|
|
|
|
|
- ActionBubble(symbol: symbol, highlighted: highlighted)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- Image(systemName: "info.circle")
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.6))
|
|
|
|
|
- .font(.system(size: 14))
|
|
|
|
|
- .frame(width: 20)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
+ ForEach(apps) { app in
|
|
|
|
|
+ PanelAppRowView(
|
|
|
|
|
+ app: app,
|
|
|
|
|
+ onAppTap: onAppTap,
|
|
|
|
|
+ draggedAppID: $draggedAppID,
|
|
|
|
|
+ lastDropDestinationAppID: $lastDropDestinationAppID,
|
|
|
|
|
+ onMove: onMove
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
- .frame(maxWidth: .infinity, alignment: .topTrailing)
|
|
|
|
|
}
|
|
}
|
|
|
.padding(.horizontal, 10)
|
|
.padding(.horizontal, 10)
|
|
|
.padding(.bottom, 18)
|
|
.padding(.bottom, 18)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct PanelAppRowView: View {
|
|
|
|
|
+ let app: LauncherApp
|
|
|
|
|
+ let onAppTap: (LauncherApp) -> Void
|
|
|
|
|
+ @Binding var draggedAppID: UUID?
|
|
|
|
|
+ @Binding var lastDropDestinationAppID: UUID?
|
|
|
|
|
+ let onMove: (UUID, UUID) -> Void
|
|
|
|
|
+
|
|
|
|
|
+ @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
|
|
|
|
|
+ @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
|
|
|
|
|
+ @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
|
|
|
|
|
+
|
|
|
|
|
+ @State private var isHovering = false
|
|
|
|
|
|
|
|
private var pinnedIDs: Set<UUID> {
|
|
private var pinnedIDs: Set<UUID> {
|
|
|
decodeUUIDSetGlobal(from: pinnedTileIDsData)
|
|
decodeUUIDSetGlobal(from: pinnedTileIDsData)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func togglePin(_ app: LauncherApp) {
|
|
|
|
|
|
|
+ private var isPinned: Bool {
|
|
|
|
|
+ pinnedIDs.contains(app.id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ ZStack(alignment: .topTrailing) {
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .contentShape(Rectangle())
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+
|
|
|
|
|
+ if isPinned && !app.isCreateNew {
|
|
|
|
|
+ Image(systemName: "pin.fill")
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
|
|
+ .frame(width: 26, height: 26)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
|
|
|
|
|
+ .padding(8)
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if !app.isCreateNew {
|
|
|
|
|
+ Menu {
|
|
|
|
|
+ Button(isPinned ? "Unpin" : "Pin") { togglePin() }
|
|
|
|
|
+ Divider()
|
|
|
|
|
+ Button("Add to StatusBar") { addToStatusBar() }
|
|
|
|
|
+ Button("Add to desktop") { addToDesktop() }
|
|
|
|
|
+ Button("Add Widget") { addWidget() }
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ Image(systemName: "ellipsis")
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .rotationEffect(.degrees(90))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
|
|
|
|
|
+ .frame(width: 26, height: 26)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
|
|
|
|
|
+ }
|
|
|
|
|
+ .menuStyle(.borderlessButton)
|
|
|
|
|
+ .modifier(HideMenuIndicatorIfAvailable())
|
|
|
|
|
+ .padding(8)
|
|
|
|
|
+ .opacity(isHovering ? 1 : 0.0)
|
|
|
|
|
+ .animation(.easeOut(duration: 0.12), value: isHovering)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .onHover { hovering in
|
|
|
|
|
+ isHovering = hovering
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.vertical, 3)
|
|
|
|
|
+ .onDrag {
|
|
|
|
|
+ draggedAppID = app.id
|
|
|
|
|
+ lastDropDestinationAppID = nil
|
|
|
|
|
+ return NSItemProvider(object: app.id.uuidString as NSString)
|
|
|
|
|
+ }
|
|
|
|
|
+ .onDrop(
|
|
|
|
|
+ of: [UTType.text],
|
|
|
|
|
+ delegate: TileReorderDropDelegate(
|
|
|
|
|
+ destinationAppID: app.id,
|
|
|
|
|
+ draggedAppID: $draggedAppID,
|
|
|
|
|
+ lastDropDestinationAppID: $lastDropDestinationAppID,
|
|
|
|
|
+ onMove: onMove
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func togglePin() {
|
|
|
var updated = pinnedIDs
|
|
var updated = pinnedIDs
|
|
|
if updated.contains(app.id) {
|
|
if updated.contains(app.id) {
|
|
|
updated.remove(app.id)
|
|
updated.remove(app.id)
|
|
@@ -515,6 +552,61 @@ private struct IconPanelView: View {
|
|
|
}
|
|
}
|
|
|
pinnedTileIDsData = encodeUUIDSetGlobal(updated)
|
|
pinnedTileIDsData = encodeUUIDSetGlobal(updated)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private func addToStatusBar() {
|
|
|
|
|
+ var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
|
|
|
|
|
+ updated.insert(app.id)
|
|
|
|
|
+ statusBarAppIDsData = encodeUUIDSetGlobal(updated)
|
|
|
|
|
+ showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func addWidget() {
|
|
|
|
|
+ var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
|
|
|
|
|
+ updated.insert(app.id)
|
|
|
|
|
+ widgetAppIDsData = encodeUUIDSetGlobal(updated)
|
|
|
|
|
+ showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func addToDesktop() {
|
|
|
|
|
+ guard let url = app.webURL else {
|
|
|
|
|
+ showAlert(title: "Unavailable", message: "This tile doesn’t have a web link to create a desktop shortcut.")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard let desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first else {
|
|
|
|
|
+ showAlert(title: "Error", message: "Couldn’t find your Desktop folder.")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let fileName = sanitizedFileName(app.name).isEmpty ? "Shortcut" : sanitizedFileName(app.name)
|
|
|
|
|
+ let targetURL = desktopURL.appendingPathComponent("\(fileName).webloc")
|
|
|
|
|
+
|
|
|
|
|
+ let payload: [String: Any] = ["URL": url.absoluteString]
|
|
|
|
|
+ do {
|
|
|
|
|
+ let data = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0)
|
|
|
|
|
+ try data.write(to: targetURL, options: .atomic)
|
|
|
|
|
+ showAlert(title: "Created", message: "Desktop shortcut created: \(targetURL.lastPathComponent)")
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showAlert(title: "Error", message: "Couldn’t create the desktop shortcut.")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func sanitizedFileName(_ input: String) -> String {
|
|
|
|
|
+ let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
|
|
|
|
+ let cleaned = input.components(separatedBy: invalid).joined(separator: " ")
|
|
|
|
|
+ return cleaned
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ .replacingOccurrences(of: " +", with: " ", options: .regularExpression)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showAlert(title: String, message: String) {
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = title
|
|
|
|
|
+ alert.informativeText = message
|
|
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
|
|
+ alert.alertStyle = .informational
|
|
|
|
|
+ alert.runModal()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private struct AppIconGlyph: View {
|
|
private struct AppIconGlyph: View {
|
|
@@ -548,22 +640,6 @@ private struct AppIconGlyph: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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: 13, weight: .medium))
|
|
|
|
|
- )
|
|
|
|
|
- .frame(width: 34, height: 34)
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// MARK: - Pinned app persistence helpers (panel mode)
|
|
// MARK: - Pinned app persistence helpers (panel mode)
|
|
|
private func decodeUUIDSetGlobal(from dataString: String) -> Set<UUID> {
|
|
private func decodeUUIDSetGlobal(from dataString: String) -> Set<UUID> {
|
|
|
guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
|
|
guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
|
|
@@ -631,6 +707,16 @@ private struct PromoBanner: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private struct HideMenuIndicatorIfAvailable: ViewModifier {
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ content.menuIndicator(.hidden)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ content
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
#Preview {
|
|
#Preview {
|
|
|
LauncherRootView()
|
|
LauncherRootView()
|
|
|
}
|
|
}
|