Add live search to macOS popover
This commit is contained in:
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
public struct StatusPopoverView: View {
|
||||
@ObservedObject public var controller: MenuBarController
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
public init(controller: MenuBarController) {
|
||||
self.controller = controller
|
||||
@@ -12,6 +13,10 @@ public struct StatusPopoverView: View {
|
||||
// Header with status
|
||||
headerSection
|
||||
|
||||
Divider()
|
||||
|
||||
searchSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Quick actions
|
||||
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
|
||||
// Footer
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.frame(width: 360)
|
||||
.padding(.vertical, 12)
|
||||
.onAppear {
|
||||
Task { @MainActor in
|
||||
searchFocused = true
|
||||
}
|
||||
}
|
||||
.onMoveCommand { direction in
|
||||
switch direction {
|
||||
case .up:
|
||||
controller.moveSearchSelection(by: -1)
|
||||
case .down:
|
||||
controller.moveSearchSelection(by: 1)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
@@ -130,6 +150,75 @@ public struct StatusPopoverView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField(
|
||||
"Search documents",
|
||||
text: Binding(
|
||||
get: { controller.searchQuery },
|
||||
set: { controller.updateSearchQuery($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onSubmit {
|
||||
controller.openSelectedSearchResult()
|
||||
}
|
||||
|
||||
if controller.isSearching {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.scaleEffect(0.65)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
searchResultsList
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchResultsList: some View {
|
||||
if controller.searchResults.isEmpty {
|
||||
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
VStack(spacing: 2) {
|
||||
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
|
||||
SearchResultRow(
|
||||
result: result,
|
||||
isSelected: index == controller.selectedSearchIndex
|
||||
) {
|
||||
controller.selectedSearchIndex = index
|
||||
controller.openSearchResult(result)
|
||||
}
|
||||
.onHover { isHovered in
|
||||
if isHovered {
|
||||
controller.selectedSearchIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentUploadsSection: some View {
|
||||
@@ -272,6 +361,47 @@ private struct RecentUploadRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchResultRow: View {
|
||||
let result: SearchResult
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isSelected ? .white : .accentColor)
|
||||
.frame(width: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.title.isEmpty ? result.path : result.title)
|
||||
.font(.caption)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.lineLimit(1)
|
||||
Text(result.path)
|
||||
.font(.caption2)
|
||||
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "return")
|
||||
.font(.caption2)
|
||||
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(height: 44)
|
||||
.background(isSelected ? Color.accentColor : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
|
||||
Reference in New Issue
Block a user