Переглянути джерело

Implement interactive top-bar search with keyboard focus shortcut.

Replace the static search label with a real input that filters meeting cards, add a centered hint with leading search icon behavior tied to focus/text state, and wire Command+E plus outside-click blur handling so focus and visual state stay in sync.

Made-with: Cursor
huzaifahayat12 3 місяців тому
батько
коміт
4702927169
1 змінених файлів з 203 додано та 9 видалено
  1. 203 9
      zoom_app/ViewController.swift

+ 203 - 9
zoom_app/ViewController.swift

@@ -49,6 +49,12 @@ class ViewController: NSViewController {
     private weak var meetingsListStack: NSStackView?
     private weak var meetingsStatusLabel: NSTextField?
     private weak var meetingsScrollView: NSScrollView?
+    private weak var homeSearchField: NSTextField?
+    private weak var homeSearchPill: NSView?
+    private var allScheduledMeetings: [ScheduledMeeting] = []
+    private var searchTextObserver: NSObjectProtocol?
+    private var searchShortcutMonitor: Any?
+    private var searchOutsideClickMonitor: Any?
     private var clockTimer: Timer?
     private var meetingsRefreshTimer: Timer?
     private var isSigningIn = false
@@ -113,6 +119,11 @@ class ViewController: NSViewController {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
         clearMeetingsScrollObserver()
+        removeSearchFieldObserver()
+        removeSearchShortcutMonitor()
+        homeSearchField = nil
+        homeSearchPill = nil
+        allScheduledMeetings = []
         homeView?.removeFromSuperview()
         homeView = nil
         isSigningIn = false
@@ -130,9 +141,14 @@ class ViewController: NSViewController {
     private func showHomeView(profile: GoogleUserProfile?) {
         loginView?.removeFromSuperview()
         clearMeetingsScrollObserver()
+        removeSearchFieldObserver()
+        removeSearchShortcutMonitor()
+        homeSearchField = nil
+        homeSearchPill = nil
         homeView?.removeFromSuperview()
         homeView = makeHomeView(profile: profile)
         if let homeView { attachToRoot(homeView) }
+        installSearchShortcutMonitor()
         persistLoggedInState(true)
         startClock()
         startMeetingsAutoRefresh()
@@ -270,6 +286,73 @@ class ViewController: NSViewController {
         meetingsScrollView = nil
     }
 
+    private func removeSearchFieldObserver() {
+        if let searchTextObserver {
+            NotificationCenter.default.removeObserver(searchTextObserver)
+        }
+        searchTextObserver = nil
+    }
+
+    private func installSearchShortcutMonitor() {
+        removeSearchShortcutMonitor()
+        searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+            guard let self else { return event }
+            guard event.modifierFlags.contains(.command),
+                  event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
+            guard self.homeSearchField != nil else { return event }
+            DispatchQueue.main.async {
+                self.focusHomeSearchField()
+            }
+            return nil
+        }
+        searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
+            guard let self else { return event }
+            guard let window = self.view.window, event.window === window else { return event }
+            guard let field = self.homeSearchField else { return event }
+            guard self.isSearchFieldActive(field, in: window) else { return event }
+            let location = event.locationInWindow
+            let pill = self.homeSearchPill ?? field
+            let rectInWindow = pill.convert(pill.bounds, to: nil)
+            if rectInWindow.contains(location) { return event }
+            DispatchQueue.main.async {
+                window.makeFirstResponder(nil)
+                (field as? SearchPillTextField)?.forceClearFocusState()
+                self.applySearchPillFocusBorder(focused: false)
+            }
+            return event
+        }
+    }
+
+    private func removeSearchShortcutMonitor() {
+        if let searchShortcutMonitor {
+            NSEvent.removeMonitor(searchShortcutMonitor)
+        }
+        searchShortcutMonitor = nil
+        if let searchOutsideClickMonitor {
+            NSEvent.removeMonitor(searchOutsideClickMonitor)
+        }
+        searchOutsideClickMonitor = nil
+    }
+
+    private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
+        guard let fr = window.firstResponder else { return false }
+        if fr === field { return true }
+        if let editor = field.currentEditor(), fr === editor { return true }
+        return false
+    }
+
+    @MainActor
+    private func applySearchPillFocusBorder(focused: Bool) {
+        homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
+        homeSearchPill?.layer?.borderColor = accentBlue.cgColor
+    }
+
+    @MainActor
+    private func focusHomeSearchField() {
+        guard let field = homeSearchField else { return }
+        view.window?.makeFirstResponder(field)
+    }
+
     private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
         clearMeetingsScrollObserver()
         meetingsScrollView = scrollView
@@ -345,16 +428,41 @@ class ViewController: NSViewController {
 
     @MainActor
     private func applyMeetings(_ meetings: [ScheduledMeeting]) {
+        allScheduledMeetings = meetings
+        applyFilteredMeetings()
+    }
+
+    @MainActor
+    private func applyFilteredMeetings() {
         guard let stack = meetingsListStack else { return }
         stack.arrangedSubviews.forEach { view in
             stack.removeArrangedSubview(view)
             view.removeFromSuperview()
         }
 
-        let ordered = meetings.sorted(by: { $0.start < $1.start })
+        let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        let source = allScheduledMeetings
+        let filtered: [ScheduledMeeting]
+        if query.isEmpty {
+            filtered = source
+        } else {
+            filtered = source.filter { meeting in
+                meeting.title.lowercased().contains(query)
+                    || meeting.host.lowercased().contains(query)
+                    || meeting.source.lowercased().contains(query)
+            }
+        }
+
+        let ordered = filtered.sorted(by: { $0.start < $1.start })
         if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = false
-            meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
+            if source.isEmpty {
+                meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
+                emptyMeetingLabel?.stringValue = "No meetings scheduled for today."
+            } else {
+                meetingsStatusLabel?.stringValue = "Zoom meetings"
+                emptyMeetingLabel?.stringValue = "No meetings match your search."
+            }
             return
         }
 
@@ -743,8 +851,39 @@ class ViewController: NSViewController {
         searchPill.wantsLayer = true
         searchPill.layer?.backgroundColor = searchPillBackground.cgColor
         searchPill.layer?.cornerRadius = 10
-        let search = makeLabel("Search (\u{2318}E)", size: 13, color: mutedText, weight: .regular, centered: true)
-        
+        searchPill.layer?.borderWidth = 0
+        let searchIcon = NSImageView()
+        searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
+        searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
+        searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
+        searchIcon.imageScaling = .scaleProportionallyUpOrDown
+        let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
+        searchHintLabel.isHidden = false
+        let searchField = SearchPillTextField()
+        searchField.isBordered = false
+        searchField.drawsBackground = false
+        searchField.backgroundColor = .clear
+        searchField.focusRingType = .none
+        searchField.font = .systemFont(ofSize: 13, weight: .regular)
+        searchField.textColor = primaryText
+        searchField.alignment = .left
+        searchField.placeholderString = nil
+        if let cell = searchField.cell as? NSTextFieldCell {
+            cell.isBezeled = false
+            cell.isBordered = false
+            cell.backgroundColor = .clear
+        }
+        let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
+            guard let searchField, let searchHintLabel else { return }
+            let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
+            searchHintLabel.isHidden = shouldShow == false
+        }
+        searchField.onFocusChange = { [weak self] focused in
+            self?.applySearchPillFocusBorder(focused: focused)
+            updateSearchHintVisibility()
+        }
+        updateSearchHintVisibility()
+
         let backForwardCluster = NSStackView()
         backForwardCluster.orientation = .horizontal
         backForwardCluster.spacing = 4
@@ -840,15 +979,18 @@ class ViewController: NSViewController {
         content.addSubview(topBarDivider)
         content.addSubview(contentColumn)
 
-        [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, search].forEach {
+        [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
         }
         [brandStack].forEach {
             shell.addSubview($0)
         }
-        [searchRow, rightTopBarCluster, search].forEach {
+        [searchRow, rightTopBarCluster].forEach {
             topBar.addSubview($0)
         }
+        [searchIcon, searchField, searchHintLabel].forEach {
+            searchPill.addSubview($0)
+        }
         [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
             contentColumn.addSubview($0)
@@ -893,9 +1035,16 @@ class ViewController: NSViewController {
 
             searchPill.heightAnchor.constraint(equalToConstant: 32),
             searchPill.widthAnchor.constraint(equalToConstant: 320),
-            search.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
-            search.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -12),
-            search.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
+            searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
+            searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
+            searchIcon.widthAnchor.constraint(equalToConstant: 16),
+            searchIcon.heightAnchor.constraint(equalToConstant: 16),
+            searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
+            searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
+            searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
+            searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
+            searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
+            searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
             profileChip.widthAnchor.constraint(equalToConstant: 34),
             profileChip.heightAnchor.constraint(equalToConstant: 34),
 
@@ -953,9 +1102,26 @@ class ViewController: NSViewController {
         emptyMeetingLabel = noMeeting
         observeMeetingsScrollEdges(in: meetingsScrollView)
         updateClock()
+
+        homeSearchField = searchField
+        homeSearchPill = searchPill
+        searchTextObserver = NotificationCenter.default.addObserver(
+            forName: NSControl.textDidChangeNotification,
+            object: searchField,
+            queue: .main
+        ) { [weak self] _ in
+            self?.applyFilteredMeetings()
+            updateSearchHintVisibility()
+        }
+
         return root
     }
 
+    deinit {
+        removeSearchFieldObserver()
+        removeSearchShortcutMonitor()
+    }
+
     private func startClock() {
         clockTimer?.invalidate()
         clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
@@ -1234,6 +1400,34 @@ class ViewController: NSViewController {
     }
 }
 
+private final class SearchPillTextField: NSTextField {
+    var onFocusChange: ((Bool) -> Void)?
+    private(set) var isSearchFocused = false
+
+    func forceClearFocusState() {
+        isSearchFocused = false
+        onFocusChange?(false)
+    }
+
+    override func becomeFirstResponder() -> Bool {
+        let ok = super.becomeFirstResponder()
+        if ok {
+            isSearchFocused = true
+            onFocusChange?(true)
+        }
+        return ok
+    }
+
+    override func resignFirstResponder() -> Bool {
+        let ok = super.resignFirstResponder()
+        if ok {
+            isSearchFocused = false
+            onFocusChange?(false)
+        }
+        return ok
+    }
+}
+
 struct GoogleOAuthTokens: Codable, Equatable {
     var accessToken: String
     var refreshToken: String?