|
|
@@ -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
|