Bläddra i källkod

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 5 dagar sedan
förälder
incheckning
4702927169
1 ändrade filer med 203 tillägg och 9 borttagningar
  1. 203 9
      zoom_app/ViewController.swift

+ 203 - 9
zoom_app/ViewController.swift

@@ -49,6 +49,12 @@ class ViewController: NSViewController {
49 49
     private weak var meetingsListStack: NSStackView?
50 50
     private weak var meetingsStatusLabel: NSTextField?
51 51
     private weak var meetingsScrollView: NSScrollView?
52
+    private weak var homeSearchField: NSTextField?
53
+    private weak var homeSearchPill: NSView?
54
+    private var allScheduledMeetings: [ScheduledMeeting] = []
55
+    private var searchTextObserver: NSObjectProtocol?
56
+    private var searchShortcutMonitor: Any?
57
+    private var searchOutsideClickMonitor: Any?
52 58
     private var clockTimer: Timer?
53 59
     private var meetingsRefreshTimer: Timer?
54 60
     private var isSigningIn = false
@@ -113,6 +119,11 @@ class ViewController: NSViewController {
113 119
         meetingsRefreshTimer?.invalidate()
114 120
         meetingsRefreshTimer = nil
115 121
         clearMeetingsScrollObserver()
122
+        removeSearchFieldObserver()
123
+        removeSearchShortcutMonitor()
124
+        homeSearchField = nil
125
+        homeSearchPill = nil
126
+        allScheduledMeetings = []
116 127
         homeView?.removeFromSuperview()
117 128
         homeView = nil
118 129
         isSigningIn = false
@@ -130,9 +141,14 @@ class ViewController: NSViewController {
130 141
     private func showHomeView(profile: GoogleUserProfile?) {
131 142
         loginView?.removeFromSuperview()
132 143
         clearMeetingsScrollObserver()
144
+        removeSearchFieldObserver()
145
+        removeSearchShortcutMonitor()
146
+        homeSearchField = nil
147
+        homeSearchPill = nil
133 148
         homeView?.removeFromSuperview()
134 149
         homeView = makeHomeView(profile: profile)
135 150
         if let homeView { attachToRoot(homeView) }
151
+        installSearchShortcutMonitor()
136 152
         persistLoggedInState(true)
137 153
         startClock()
138 154
         startMeetingsAutoRefresh()
@@ -270,6 +286,73 @@ class ViewController: NSViewController {
270 286
         meetingsScrollView = nil
271 287
     }
272 288
 
289
+    private func removeSearchFieldObserver() {
290
+        if let searchTextObserver {
291
+            NotificationCenter.default.removeObserver(searchTextObserver)
292
+        }
293
+        searchTextObserver = nil
294
+    }
295
+
296
+    private func installSearchShortcutMonitor() {
297
+        removeSearchShortcutMonitor()
298
+        searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
299
+            guard let self else { return event }
300
+            guard event.modifierFlags.contains(.command),
301
+                  event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
302
+            guard self.homeSearchField != nil else { return event }
303
+            DispatchQueue.main.async {
304
+                self.focusHomeSearchField()
305
+            }
306
+            return nil
307
+        }
308
+        searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
309
+            guard let self else { return event }
310
+            guard let window = self.view.window, event.window === window else { return event }
311
+            guard let field = self.homeSearchField else { return event }
312
+            guard self.isSearchFieldActive(field, in: window) else { return event }
313
+            let location = event.locationInWindow
314
+            let pill = self.homeSearchPill ?? field
315
+            let rectInWindow = pill.convert(pill.bounds, to: nil)
316
+            if rectInWindow.contains(location) { return event }
317
+            DispatchQueue.main.async {
318
+                window.makeFirstResponder(nil)
319
+                (field as? SearchPillTextField)?.forceClearFocusState()
320
+                self.applySearchPillFocusBorder(focused: false)
321
+            }
322
+            return event
323
+        }
324
+    }
325
+
326
+    private func removeSearchShortcutMonitor() {
327
+        if let searchShortcutMonitor {
328
+            NSEvent.removeMonitor(searchShortcutMonitor)
329
+        }
330
+        searchShortcutMonitor = nil
331
+        if let searchOutsideClickMonitor {
332
+            NSEvent.removeMonitor(searchOutsideClickMonitor)
333
+        }
334
+        searchOutsideClickMonitor = nil
335
+    }
336
+
337
+    private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
338
+        guard let fr = window.firstResponder else { return false }
339
+        if fr === field { return true }
340
+        if let editor = field.currentEditor(), fr === editor { return true }
341
+        return false
342
+    }
343
+
344
+    @MainActor
345
+    private func applySearchPillFocusBorder(focused: Bool) {
346
+        homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
347
+        homeSearchPill?.layer?.borderColor = accentBlue.cgColor
348
+    }
349
+
350
+    @MainActor
351
+    private func focusHomeSearchField() {
352
+        guard let field = homeSearchField else { return }
353
+        view.window?.makeFirstResponder(field)
354
+    }
355
+
273 356
     private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
274 357
         clearMeetingsScrollObserver()
275 358
         meetingsScrollView = scrollView
@@ -345,16 +428,41 @@ class ViewController: NSViewController {
345 428
 
346 429
     @MainActor
347 430
     private func applyMeetings(_ meetings: [ScheduledMeeting]) {
431
+        allScheduledMeetings = meetings
432
+        applyFilteredMeetings()
433
+    }
434
+
435
+    @MainActor
436
+    private func applyFilteredMeetings() {
348 437
         guard let stack = meetingsListStack else { return }
349 438
         stack.arrangedSubviews.forEach { view in
350 439
             stack.removeArrangedSubview(view)
351 440
             view.removeFromSuperview()
352 441
         }
353 442
 
354
-        let ordered = meetings.sorted(by: { $0.start < $1.start })
443
+        let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
444
+        let source = allScheduledMeetings
445
+        let filtered: [ScheduledMeeting]
446
+        if query.isEmpty {
447
+            filtered = source
448
+        } else {
449
+            filtered = source.filter { meeting in
450
+                meeting.title.lowercased().contains(query)
451
+                    || meeting.host.lowercased().contains(query)
452
+                    || meeting.source.lowercased().contains(query)
453
+            }
454
+        }
455
+
456
+        let ordered = filtered.sorted(by: { $0.start < $1.start })
355 457
         if ordered.isEmpty {
356 458
             emptyMeetingLabel?.isHidden = false
357
-            meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
459
+            if source.isEmpty {
460
+                meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
461
+                emptyMeetingLabel?.stringValue = "No meetings scheduled for today."
462
+            } else {
463
+                meetingsStatusLabel?.stringValue = "Zoom meetings"
464
+                emptyMeetingLabel?.stringValue = "No meetings match your search."
465
+            }
358 466
             return
359 467
         }
360 468
 
@@ -743,8 +851,39 @@ class ViewController: NSViewController {
743 851
         searchPill.wantsLayer = true
744 852
         searchPill.layer?.backgroundColor = searchPillBackground.cgColor
745 853
         searchPill.layer?.cornerRadius = 10
746
-        let search = makeLabel("Search (\u{2318}E)", size: 13, color: mutedText, weight: .regular, centered: true)
747
-        
854
+        searchPill.layer?.borderWidth = 0
855
+        let searchIcon = NSImageView()
856
+        searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
857
+        searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
858
+        searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
859
+        searchIcon.imageScaling = .scaleProportionallyUpOrDown
860
+        let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
861
+        searchHintLabel.isHidden = false
862
+        let searchField = SearchPillTextField()
863
+        searchField.isBordered = false
864
+        searchField.drawsBackground = false
865
+        searchField.backgroundColor = .clear
866
+        searchField.focusRingType = .none
867
+        searchField.font = .systemFont(ofSize: 13, weight: .regular)
868
+        searchField.textColor = primaryText
869
+        searchField.alignment = .left
870
+        searchField.placeholderString = nil
871
+        if let cell = searchField.cell as? NSTextFieldCell {
872
+            cell.isBezeled = false
873
+            cell.isBordered = false
874
+            cell.backgroundColor = .clear
875
+        }
876
+        let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
877
+            guard let searchField, let searchHintLabel else { return }
878
+            let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
879
+            searchHintLabel.isHidden = shouldShow == false
880
+        }
881
+        searchField.onFocusChange = { [weak self] focused in
882
+            self?.applySearchPillFocusBorder(focused: focused)
883
+            updateSearchHintVisibility()
884
+        }
885
+        updateSearchHintVisibility()
886
+
748 887
         let backForwardCluster = NSStackView()
749 888
         backForwardCluster.orientation = .horizontal
750 889
         backForwardCluster.spacing = 4
@@ -840,15 +979,18 @@ class ViewController: NSViewController {
840 979
         content.addSubview(topBarDivider)
841 980
         content.addSubview(contentColumn)
842 981
 
843
-        [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, search].forEach {
982
+        [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
844 983
             $0.translatesAutoresizingMaskIntoConstraints = false
845 984
         }
846 985
         [brandStack].forEach {
847 986
             shell.addSubview($0)
848 987
         }
849
-        [searchRow, rightTopBarCluster, search].forEach {
988
+        [searchRow, rightTopBarCluster].forEach {
850 989
             topBar.addSubview($0)
851 990
         }
991
+        [searchIcon, searchField, searchHintLabel].forEach {
992
+            searchPill.addSubview($0)
993
+        }
852 994
         [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
853 995
             $0.translatesAutoresizingMaskIntoConstraints = false
854 996
             contentColumn.addSubview($0)
@@ -893,9 +1035,16 @@ class ViewController: NSViewController {
893 1035
 
894 1036
             searchPill.heightAnchor.constraint(equalToConstant: 32),
895 1037
             searchPill.widthAnchor.constraint(equalToConstant: 320),
896
-            search.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
897
-            search.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -12),
898
-            search.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
1038
+            searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
1039
+            searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
1040
+            searchIcon.widthAnchor.constraint(equalToConstant: 16),
1041
+            searchIcon.heightAnchor.constraint(equalToConstant: 16),
1042
+            searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
1043
+            searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
1044
+            searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
1045
+            searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
1046
+            searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
1047
+            searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
899 1048
             profileChip.widthAnchor.constraint(equalToConstant: 34),
900 1049
             profileChip.heightAnchor.constraint(equalToConstant: 34),
901 1050
 
@@ -953,9 +1102,26 @@ class ViewController: NSViewController {
953 1102
         emptyMeetingLabel = noMeeting
954 1103
         observeMeetingsScrollEdges(in: meetingsScrollView)
955 1104
         updateClock()
1105
+
1106
+        homeSearchField = searchField
1107
+        homeSearchPill = searchPill
1108
+        searchTextObserver = NotificationCenter.default.addObserver(
1109
+            forName: NSControl.textDidChangeNotification,
1110
+            object: searchField,
1111
+            queue: .main
1112
+        ) { [weak self] _ in
1113
+            self?.applyFilteredMeetings()
1114
+            updateSearchHintVisibility()
1115
+        }
1116
+
956 1117
         return root
957 1118
     }
958 1119
 
1120
+    deinit {
1121
+        removeSearchFieldObserver()
1122
+        removeSearchShortcutMonitor()
1123
+    }
1124
+
959 1125
     private func startClock() {
960 1126
         clockTimer?.invalidate()
961 1127
         clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
@@ -1234,6 +1400,34 @@ class ViewController: NSViewController {
1234 1400
     }
1235 1401
 }
1236 1402
 
1403
+private final class SearchPillTextField: NSTextField {
1404
+    var onFocusChange: ((Bool) -> Void)?
1405
+    private(set) var isSearchFocused = false
1406
+
1407
+    func forceClearFocusState() {
1408
+        isSearchFocused = false
1409
+        onFocusChange?(false)
1410
+    }
1411
+
1412
+    override func becomeFirstResponder() -> Bool {
1413
+        let ok = super.becomeFirstResponder()
1414
+        if ok {
1415
+            isSearchFocused = true
1416
+            onFocusChange?(true)
1417
+        }
1418
+        return ok
1419
+    }
1420
+
1421
+    override func resignFirstResponder() -> Bool {
1422
+        let ok = super.resignFirstResponder()
1423
+        if ok {
1424
+            isSearchFocused = false
1425
+            onFocusChange?(false)
1426
+        }
1427
+        return ok
1428
+    }
1429
+}
1430
+
1237 1431
 struct GoogleOAuthTokens: Codable, Equatable {
1238 1432
     var accessToken: String
1239 1433
     var refreshToken: String?