Browse Source

Add join-meeting sheet with URL or ID + passcode, open in browser

- Show styled NSPanel from Join action: segmented URL vs ID, passcode for ID mode
- Hide traffic lights; custom header with close; dark-mode aligned chrome
- Open Zoom links via NSWorkspace in default browser; wc/join URL for ID flow
- Join form fields: pill container, centered text, custom cell for vertical center
- Panel key window and first-responder focus; avoid clipping field editor

Made-with: Cursor
huzaifahayat12 4 days ago
parent
commit
5977e6f64c
1 changed files with 460 additions and 1 deletions
  1. 460 1
      zoom_app/ViewController.swift

+ 460 - 1
zoom_app/ViewController.swift

@@ -189,6 +189,13 @@ class ViewController: NSViewController {
189 189
     private weak var settingsGoogleActionButton: NSButton?
190 190
     private weak var topBarPremiumButton: NSButton?
191 191
     private var paywallWindow: NSWindow?
192
+    private var joinMeetingWindow: NSWindow?
193
+    private weak var joinURLField: NSTextField?
194
+    private weak var joinMeetingIDField: NSTextField?
195
+    private weak var joinPasscodeField: NSTextField?
196
+    private weak var joinURLFieldsContainer: NSView?
197
+    private weak var joinIDFieldsContainer: NSView?
198
+    private weak var joinModeSegment: NSSegmentedControl?
192 199
     private let paywallContentWidth: CGFloat = 520
193 200
     private var selectedPremiumPlan: PremiumPlan = .monthly
194 201
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
@@ -551,6 +558,301 @@ class ViewController: NSViewController {
551 558
         }
552 559
     }
553 560
 
561
+    @objc private func joinMeetingTapped() {
562
+        if let existing = joinMeetingWindow {
563
+            existing.makeKeyAndOrderFront(nil)
564
+            NSApp.activate(ignoringOtherApps: true)
565
+            DispatchQueue.main.async { [weak self] in
566
+                _ = self?.joinURLField?.becomeFirstResponder()
567
+            }
568
+            return
569
+        }
570
+
571
+        let content = makeJoinMeetingPanelContent()
572
+        let controller = NSViewController()
573
+        controller.view = content
574
+
575
+        let panel = NSPanel(
576
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
577
+            styleMask: [.titled, .closable, .fullSizeContentView],
578
+            backing: .buffered,
579
+            defer: false
580
+        )
581
+        panel.title = "Join a meeting"
582
+        panel.titleVisibility = .hidden
583
+        panel.titlebarAppearsTransparent = true
584
+        panel.hidesOnDeactivate = true
585
+        panel.isReleasedWhenClosed = false
586
+        panel.standardWindowButton(.closeButton)?.isHidden = true
587
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
588
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
589
+        panel.isMovableByWindowBackground = true
590
+        // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
591
+        panel.becomesKeyOnlyIfNeeded = false
592
+        panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
593
+        panel.center()
594
+        panel.contentViewController = controller
595
+        panel.delegate = self
596
+        applyWindowBackgroundForCurrentTheme(panel)
597
+        panel.makeKeyAndOrderFront(nil)
598
+        NSApp.activate(ignoringOtherApps: true)
599
+        joinMeetingWindow = panel
600
+        DispatchQueue.main.async { [weak self] in
601
+            guard let self else { return }
602
+            _ = self.joinURLField?.becomeFirstResponder()
603
+        }
604
+    }
605
+
606
+    private func makeJoinMeetingPanelContent() -> NSView {
607
+        let root = NSView()
608
+        root.translatesAutoresizingMaskIntoConstraints = false
609
+        root.wantsLayer = true
610
+        root.layer?.backgroundColor = appBackground.cgColor
611
+
612
+        let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
613
+        let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
614
+
615
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
616
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
617
+        closeButton.isBordered = false
618
+        closeButton.bezelStyle = .regularSquare
619
+        closeButton.wantsLayer = true
620
+        closeButton.layer?.cornerRadius = 14
621
+        closeButton.normalColor = palette.inputBackground
622
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
623
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
624
+        closeButton.layer?.borderWidth = 1
625
+        closeButton.font = .systemFont(ofSize: 13, weight: .bold)
626
+        closeButton.contentTintColor = secondaryText
627
+        closeButton.toolTip = "Close"
628
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
629
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
630
+
631
+        let titleRow = NSStackView()
632
+        titleRow.orientation = .horizontal
633
+        titleRow.alignment = .centerY
634
+        titleRow.spacing = 10
635
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
636
+        let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
637
+        titleStack.orientation = .vertical
638
+        titleStack.spacing = 2
639
+        titleStack.alignment = .leading
640
+        let titleSpacer = NSView()
641
+        titleSpacer.translatesAutoresizingMaskIntoConstraints = false
642
+        titleRow.addArrangedSubview(titleStack)
643
+        titleRow.addArrangedSubview(titleSpacer)
644
+        titleRow.addArrangedSubview(closeButton)
645
+
646
+        let headerDivider = NSView()
647
+        headerDivider.wantsLayer = true
648
+        headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
649
+        headerDivider.translatesAutoresizingMaskIntoConstraints = false
650
+
651
+        let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
652
+        mode.segmentStyle = .rounded
653
+        mode.selectedSegment = 0
654
+        mode.translatesAutoresizingMaskIntoConstraints = false
655
+        mode.font = .systemFont(ofSize: 12, weight: .semibold)
656
+        mode.controlSize = .large
657
+        if #available(macOS 11.0, *) {
658
+            mode.selectedSegmentBezelColor = accentBlue
659
+        }
660
+        joinModeSegment = mode
661
+
662
+        let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
663
+        joinURLField = urlBox.textField
664
+        let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
665
+        let urlStack = NSStackView(views: [urlLabel, urlBox])
666
+        urlStack.orientation = .vertical
667
+        urlStack.spacing = 8
668
+        urlStack.alignment = .leading
669
+        urlStack.translatesAutoresizingMaskIntoConstraints = false
670
+        joinURLFieldsContainer = urlStack
671
+
672
+        let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
673
+        let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
674
+        joinMeetingIDField = idBox.textField
675
+        joinPasscodeField = passBox.textField
676
+        let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
677
+        let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
678
+        let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
679
+        idStack.orientation = .vertical
680
+        idStack.spacing = 8
681
+        idStack.alignment = .leading
682
+        idStack.translatesAutoresizingMaskIntoConstraints = false
683
+        idStack.isHidden = true
684
+        joinIDFieldsContainer = idStack
685
+
686
+        let formCard = NSView()
687
+        formCard.translatesAutoresizingMaskIntoConstraints = false
688
+        formCard.wantsLayer = true
689
+        formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
690
+        formCard.layer?.cornerRadius = 14
691
+        formCard.layer?.borderWidth = 1
692
+        formCard.layer?.borderColor = palette.inputBorder.cgColor
693
+
694
+        let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
695
+        cancelButton.isBordered = false
696
+        cancelButton.wantsLayer = true
697
+        cancelButton.layer?.cornerRadius = 10
698
+        cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
699
+        cancelButton.layer?.borderWidth = 1
700
+        cancelButton.layer?.borderColor = palette.inputBorder.cgColor
701
+        cancelButton.contentTintColor = primaryText
702
+        cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
703
+
704
+        let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
705
+        joinButton.isBordered = false
706
+        joinButton.wantsLayer = true
707
+        joinButton.layer?.cornerRadius = 10
708
+        joinButton.normalColor = accentBlue
709
+        joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
710
+        joinButton.contentTintColor = .white
711
+        joinButton.font = .systemFont(ofSize: 13, weight: .bold)
712
+        joinButton.keyEquivalent = "\r"
713
+
714
+        let buttons = NSStackView(views: [cancelButton, joinButton])
715
+        buttons.orientation = .horizontal
716
+        buttons.spacing = 12
717
+        buttons.alignment = .centerY
718
+        buttons.distribution = .fillEqually
719
+
720
+        let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
721
+        innerStack.orientation = .vertical
722
+        innerStack.spacing = 18
723
+        innerStack.alignment = .leading
724
+        innerStack.translatesAutoresizingMaskIntoConstraints = false
725
+        formCard.addSubview(innerStack)
726
+
727
+        [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
728
+
729
+        NSLayoutConstraint.activate([
730
+            titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
731
+            titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
732
+            titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
733
+
734
+            headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
735
+            headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
736
+            headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
737
+            headerDivider.heightAnchor.constraint(equalToConstant: 1),
738
+
739
+            formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
740
+            formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
741
+            formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
742
+            formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
743
+
744
+            innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
745
+            innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
746
+            innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
747
+            innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
748
+
749
+            mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
750
+            urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
751
+            idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
752
+            passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
753
+            buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
754
+            joinButton.heightAnchor.constraint(equalToConstant: 40),
755
+            cancelButton.heightAnchor.constraint(equalToConstant: 40)
756
+        ])
757
+
758
+        return root
759
+    }
760
+
761
+    private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
762
+        JoinPanelFieldContainer(
763
+            placeholder: placeholder,
764
+            normalBorder: palette.inputBorder,
765
+            focusBorder: accentBlue.withAlphaComponent(0.9),
766
+            fill: palette.inputBackground,
767
+            primaryText: primaryText,
768
+            mutedText: mutedText
769
+        )
770
+    }
771
+
772
+    @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
773
+        let urlMode = sender.selectedSegment == 0
774
+        joinURLFieldsContainer?.isHidden = urlMode == false
775
+        joinIDFieldsContainer?.isHidden = urlMode
776
+    }
777
+
778
+    @objc private func joinMeetingCancelTapped() {
779
+        joinMeetingWindow?.performClose(nil)
780
+    }
781
+
782
+    @objc private func joinMeetingSubmitTapped() {
783
+        guard let segment = joinModeSegment else { return }
784
+        if segment.selectedSegment == 0 {
785
+            let raw = joinURLField?.stringValue ?? ""
786
+            guard let url = parseZoomJoinURLFromUserInput(raw) else {
787
+                showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
788
+                return
789
+            }
790
+            openZoomMeetingInDefaultBrowser(url)
791
+        } else {
792
+            let idRaw = joinMeetingIDField?.stringValue ?? ""
793
+            let digits = idRaw.filter(\.isNumber)
794
+            guard digits.isEmpty == false else {
795
+                showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
796
+                return
797
+            }
798
+            let pass = joinPasscodeField?.stringValue ?? ""
799
+            guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
800
+                showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
801
+                return
802
+            }
803
+            openZoomMeetingInDefaultBrowser(url)
804
+        }
805
+    }
806
+
807
+    /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
808
+    private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
809
+        guard meetingIdDigits.isEmpty == false else { return nil }
810
+        var components = URLComponents()
811
+        components.scheme = "https"
812
+        components.host = "zoom.us"
813
+        components.path = "/wc/join/\(meetingIdDigits)"
814
+        let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
815
+        if trimmed.isEmpty == false {
816
+            components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
817
+        }
818
+        return components.url
819
+    }
820
+
821
+    private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
822
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
823
+        guard trimmed.isEmpty == false else { return nil }
824
+        let normalized: String
825
+        if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
826
+            normalized = trimmed
827
+        } else {
828
+            normalized = "https://\(trimmed)"
829
+        }
830
+        guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
831
+        let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
832
+            || host == "zoom.com" || host.hasSuffix(".zoom.com")
833
+        guard isZoom else { return nil }
834
+        return url
835
+    }
836
+
837
+    private func openZoomMeetingInDefaultBrowser(_ url: URL) {
838
+        let opened = NSWorkspace.shared.open(url)
839
+        if opened {
840
+            joinMeetingWindow?.performClose(nil)
841
+        } else {
842
+            showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
843
+        }
844
+    }
845
+
846
+    private func resetJoinMeetingPanelReferences() {
847
+        joinMeetingWindow = nil
848
+        joinURLField = nil
849
+        joinMeetingIDField = nil
850
+        joinPasscodeField = nil
851
+        joinURLFieldsContainer = nil
852
+        joinIDFieldsContainer = nil
853
+        joinModeSegment = nil
854
+    }
855
+
554 856
     @objc private func logoutTapped() {
555 857
         meetingsRefreshTimer?.invalidate()
556 858
         meetingsRefreshTimer = nil
@@ -2288,7 +2590,7 @@ class ViewController: NSViewController {
2288 2590
 
2289 2591
         let actions = NSStackView(views: [
2290 2592
             makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
2291
-            makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
2593
+            makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
2292 2594
             makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
2293 2595
         ])
2294 2596
         actions.orientation = .horizontal
@@ -3105,6 +3407,13 @@ class ViewController: NSViewController {
3105 3407
     }
3106 3408
 }
3107 3409
 
3410
+extension ViewController: NSWindowDelegate {
3411
+    func windowWillClose(_ notification: Notification) {
3412
+        guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
3413
+        resetJoinMeetingPanelReferences()
3414
+    }
3415
+}
3416
+
3108 3417
 private extension Array {
3109 3418
     subscript(safe index: Int) -> Element? {
3110 3419
         guard index >= 0, index < count else { return nil }
@@ -3112,6 +3421,156 @@ private extension Array {
3112 3421
     }
3113 3422
 }
3114 3423
 
3424
+/// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
3425
+private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
3426
+    private func lineHeight() -> CGFloat {
3427
+        guard let font = font else { return 0 }
3428
+        return ceil(font.ascender - font.descender + font.leading)
3429
+    }
3430
+
3431
+    private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
3432
+        let lh = lineHeight()
3433
+        guard lh > 0, h > lh else { return 0 }
3434
+        return max(0, floor((h - lh) / 2))
3435
+    }
3436
+
3437
+    private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
3438
+        let lh = lineHeight()
3439
+        guard lh > 0 else { return cellFrame }
3440
+        let m = verticalMargin(forBoundsHeight: cellFrame.height)
3441
+        var r = cellFrame
3442
+        r.origin.y += m
3443
+        r.size.height = lh
3444
+        return r
3445
+    }
3446
+
3447
+    override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
3448
+        super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
3449
+    }
3450
+
3451
+    override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
3452
+        let text = super.setUpFieldEditorAttributes(textObj)
3453
+        guard let tv = text as? NSTextView else { return text }
3454
+        // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
3455
+        let h = max(controlView?.bounds.height ?? 0, 42)
3456
+        let margin = verticalMargin(forBoundsHeight: h)
3457
+        if margin > 0 {
3458
+            // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
3459
+            tv.textContainerInset = NSSize(width: 0, height: margin)
3460
+        }
3461
+        return text
3462
+    }
3463
+}
3464
+
3465
+/// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
3466
+private final class JoinPanelFieldContainer: NSView {
3467
+    let textField: NSTextField
3468
+    private let normalBorder: NSColor
3469
+    private let focusBorder: NSColor
3470
+    private let fill: NSColor
3471
+    private var beginObserver: NSObjectProtocol?
3472
+    private var endObserver: NSObjectProtocol?
3473
+
3474
+    init(
3475
+        placeholder: String,
3476
+        normalBorder: NSColor,
3477
+        focusBorder: NSColor,
3478
+        fill: NSColor,
3479
+        primaryText: NSColor,
3480
+        mutedText: NSColor
3481
+    ) {
3482
+        self.normalBorder = normalBorder
3483
+        self.focusBorder = focusBorder
3484
+        self.fill = fill
3485
+        textField = NSTextField()
3486
+        textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
3487
+        super.init(frame: .zero)
3488
+        translatesAutoresizingMaskIntoConstraints = false
3489
+        wantsLayer = true
3490
+        // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
3491
+        layer?.masksToBounds = false
3492
+        layer?.cornerRadius = 23
3493
+        layer?.backgroundColor = fill.cgColor
3494
+        applyBorder(focused: false)
3495
+
3496
+        textField.font = .systemFont(ofSize: 14, weight: .regular)
3497
+        textField.textColor = primaryText
3498
+        textField.alignment = .center
3499
+        textField.isEditable = true
3500
+        textField.isSelectable = true
3501
+        textField.refusesFirstResponder = false
3502
+        textField.focusRingType = .none
3503
+        textField.isBordered = false
3504
+        textField.drawsBackground = false
3505
+        textField.translatesAutoresizingMaskIntoConstraints = false
3506
+        if let cell = textField.cell as? NSTextFieldCell {
3507
+            cell.isBezeled = false
3508
+            cell.drawsBackground = false
3509
+            cell.alignment = .center
3510
+            cell.usesSingleLineMode = true
3511
+            cell.lineBreakMode = .byTruncatingTail
3512
+            let placeholderParagraph = NSMutableParagraphStyle()
3513
+            placeholderParagraph.alignment = .center
3514
+            cell.placeholderAttributedString = NSAttributedString(
3515
+                string: placeholder,
3516
+                attributes: [
3517
+                    .foregroundColor: mutedText.withAlphaComponent(0.88),
3518
+                    .font: NSFont.systemFont(ofSize: 14, weight: .regular),
3519
+                    .paragraphStyle: placeholderParagraph
3520
+                ]
3521
+            )
3522
+        }
3523
+        addSubview(textField)
3524
+        NSLayoutConstraint.activate([
3525
+            textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
3526
+            textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
3527
+            textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
3528
+            textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
3529
+            heightAnchor.constraint(equalToConstant: 46)
3530
+        ])
3531
+
3532
+        beginObserver = NotificationCenter.default.addObserver(
3533
+            forName: NSControl.textDidBeginEditingNotification,
3534
+            object: textField,
3535
+            queue: .main
3536
+        ) { [weak self] _ in
3537
+            self?.applyBorder(focused: true)
3538
+        }
3539
+        endObserver = NotificationCenter.default.addObserver(
3540
+            forName: NSControl.textDidEndEditingNotification,
3541
+            object: textField,
3542
+            queue: .main
3543
+        ) { [weak self] _ in
3544
+            self?.applyBorder(focused: false)
3545
+        }
3546
+    }
3547
+
3548
+    required init?(coder: NSCoder) {
3549
+        fatalError("init(coder:) has not been implemented")
3550
+    }
3551
+
3552
+    deinit {
3553
+        if let beginObserver {
3554
+            NotificationCenter.default.removeObserver(beginObserver)
3555
+        }
3556
+        if let endObserver {
3557
+            NotificationCenter.default.removeObserver(endObserver)
3558
+        }
3559
+    }
3560
+
3561
+    private func applyBorder(focused: Bool) {
3562
+        layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
3563
+        layer?.borderWidth = focused ? 1.5 : 1
3564
+    }
3565
+
3566
+    override func viewDidMoveToWindow() {
3567
+        super.viewDidMoveToWindow()
3568
+        if window == nil {
3569
+            applyBorder(focused: false)
3570
+        }
3571
+    }
3572
+}
3573
+
3115 3574
 private final class SearchPillTextField: NSTextField {
3116 3575
     var onFocusChange: ((Bool) -> Void)?
3117 3576
     private(set) var isSearchFocused = false