|
|
@@ -189,6 +189,13 @@ class ViewController: NSViewController {
|
|
|
private weak var settingsGoogleActionButton: NSButton?
|
|
|
private weak var topBarPremiumButton: NSButton?
|
|
|
private var paywallWindow: NSWindow?
|
|
|
+ private var joinMeetingWindow: NSWindow?
|
|
|
+ private weak var joinURLField: NSTextField?
|
|
|
+ private weak var joinMeetingIDField: NSTextField?
|
|
|
+ private weak var joinPasscodeField: NSTextField?
|
|
|
+ private weak var joinURLFieldsContainer: NSView?
|
|
|
+ private weak var joinIDFieldsContainer: NSView?
|
|
|
+ private weak var joinModeSegment: NSSegmentedControl?
|
|
|
private let paywallContentWidth: CGFloat = 520
|
|
|
private var selectedPremiumPlan: PremiumPlan = .monthly
|
|
|
private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
|
@@ -551,6 +558,301 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ @objc private func joinMeetingTapped() {
|
|
|
+ if let existing = joinMeetingWindow {
|
|
|
+ existing.makeKeyAndOrderFront(nil)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
+ _ = self?.joinURLField?.becomeFirstResponder()
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let content = makeJoinMeetingPanelContent()
|
|
|
+ let controller = NSViewController()
|
|
|
+ controller.view = content
|
|
|
+
|
|
|
+ let panel = NSPanel(
|
|
|
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
|
|
|
+ styleMask: [.titled, .closable, .fullSizeContentView],
|
|
|
+ backing: .buffered,
|
|
|
+ defer: false
|
|
|
+ )
|
|
|
+ panel.title = "Join a meeting"
|
|
|
+ panel.titleVisibility = .hidden
|
|
|
+ panel.titlebarAppearsTransparent = true
|
|
|
+ panel.hidesOnDeactivate = true
|
|
|
+ panel.isReleasedWhenClosed = false
|
|
|
+ panel.standardWindowButton(.closeButton)?.isHidden = true
|
|
|
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
|
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
+ panel.isMovableByWindowBackground = true
|
|
|
+ // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
|
|
|
+ panel.becomesKeyOnlyIfNeeded = false
|
|
|
+ panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
|
|
|
+ panel.center()
|
|
|
+ panel.contentViewController = controller
|
|
|
+ panel.delegate = self
|
|
|
+ applyWindowBackgroundForCurrentTheme(panel)
|
|
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ joinMeetingWindow = panel
|
|
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ _ = self.joinURLField?.becomeFirstResponder()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeJoinMeetingPanelContent() -> NSView {
|
|
|
+ let root = NSView()
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ root.wantsLayer = true
|
|
|
+ root.layer?.backgroundColor = appBackground.cgColor
|
|
|
+
|
|
|
+ let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
|
|
|
+ let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
|
|
|
+
|
|
|
+ let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
|
|
|
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ closeButton.isBordered = false
|
|
|
+ closeButton.bezelStyle = .regularSquare
|
|
|
+ closeButton.wantsLayer = true
|
|
|
+ closeButton.layer?.cornerRadius = 14
|
|
|
+ closeButton.normalColor = palette.inputBackground
|
|
|
+ closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
|
|
|
+ closeButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ closeButton.layer?.borderWidth = 1
|
|
|
+ closeButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
+ closeButton.contentTintColor = secondaryText
|
|
|
+ closeButton.toolTip = "Close"
|
|
|
+ closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
+ closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
+
|
|
|
+ let titleRow = NSStackView()
|
|
|
+ titleRow.orientation = .horizontal
|
|
|
+ titleRow.alignment = .centerY
|
|
|
+ titleRow.spacing = 10
|
|
|
+ titleRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
|
|
|
+ titleStack.orientation = .vertical
|
|
|
+ titleStack.spacing = 2
|
|
|
+ titleStack.alignment = .leading
|
|
|
+ let titleSpacer = NSView()
|
|
|
+ titleSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ titleRow.addArrangedSubview(titleStack)
|
|
|
+ titleRow.addArrangedSubview(titleSpacer)
|
|
|
+ titleRow.addArrangedSubview(closeButton)
|
|
|
+
|
|
|
+ let headerDivider = NSView()
|
|
|
+ headerDivider.wantsLayer = true
|
|
|
+ headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
|
|
|
+ headerDivider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
|
|
|
+ mode.segmentStyle = .rounded
|
|
|
+ mode.selectedSegment = 0
|
|
|
+ mode.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ mode.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
+ mode.controlSize = .large
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
+ mode.selectedSegmentBezelColor = accentBlue
|
|
|
+ }
|
|
|
+ joinModeSegment = mode
|
|
|
+
|
|
|
+ let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
|
|
|
+ joinURLField = urlBox.textField
|
|
|
+ let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ let urlStack = NSStackView(views: [urlLabel, urlBox])
|
|
|
+ urlStack.orientation = .vertical
|
|
|
+ urlStack.spacing = 8
|
|
|
+ urlStack.alignment = .leading
|
|
|
+ urlStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ joinURLFieldsContainer = urlStack
|
|
|
+
|
|
|
+ let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
|
|
|
+ let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
|
|
|
+ joinMeetingIDField = idBox.textField
|
|
|
+ joinPasscodeField = passBox.textField
|
|
|
+ let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
|
|
|
+ idStack.orientation = .vertical
|
|
|
+ idStack.spacing = 8
|
|
|
+ idStack.alignment = .leading
|
|
|
+ idStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ idStack.isHidden = true
|
|
|
+ joinIDFieldsContainer = idStack
|
|
|
+
|
|
|
+ let formCard = NSView()
|
|
|
+ formCard.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ formCard.wantsLayer = true
|
|
|
+ formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
|
|
|
+ formCard.layer?.cornerRadius = 14
|
|
|
+ formCard.layer?.borderWidth = 1
|
|
|
+ formCard.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+
|
|
|
+ let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
|
|
|
+ cancelButton.isBordered = false
|
|
|
+ cancelButton.wantsLayer = true
|
|
|
+ cancelButton.layer?.cornerRadius = 10
|
|
|
+ cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ cancelButton.layer?.borderWidth = 1
|
|
|
+ cancelButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ cancelButton.contentTintColor = primaryText
|
|
|
+ cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+
|
|
|
+ let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
|
|
|
+ joinButton.isBordered = false
|
|
|
+ joinButton.wantsLayer = true
|
|
|
+ joinButton.layer?.cornerRadius = 10
|
|
|
+ joinButton.normalColor = accentBlue
|
|
|
+ joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
|
|
|
+ joinButton.contentTintColor = .white
|
|
|
+ joinButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
+ joinButton.keyEquivalent = "\r"
|
|
|
+
|
|
|
+ let buttons = NSStackView(views: [cancelButton, joinButton])
|
|
|
+ buttons.orientation = .horizontal
|
|
|
+ buttons.spacing = 12
|
|
|
+ buttons.alignment = .centerY
|
|
|
+ buttons.distribution = .fillEqually
|
|
|
+
|
|
|
+ let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
|
|
|
+ innerStack.orientation = .vertical
|
|
|
+ innerStack.spacing = 18
|
|
|
+ innerStack.alignment = .leading
|
|
|
+ innerStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ formCard.addSubview(innerStack)
|
|
|
+
|
|
|
+ [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
+ titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
|
|
|
+ titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
|
|
|
+
|
|
|
+ headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
|
|
|
+ headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
+ headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
|
|
|
+ headerDivider.heightAnchor.constraint(equalToConstant: 1),
|
|
|
+
|
|
|
+ formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
|
|
|
+ formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
|
|
|
+ formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
|
|
|
+ formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
|
|
|
+
|
|
|
+ innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
|
|
|
+ innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
|
|
|
+ innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
|
|
|
+ innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
|
|
|
+
|
|
|
+ mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
+ urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
+ idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
+ passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
+ buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
|
|
|
+ joinButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
+ cancelButton.heightAnchor.constraint(equalToConstant: 40)
|
|
|
+ ])
|
|
|
+
|
|
|
+ return root
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
|
|
|
+ JoinPanelFieldContainer(
|
|
|
+ placeholder: placeholder,
|
|
|
+ normalBorder: palette.inputBorder,
|
|
|
+ focusBorder: accentBlue.withAlphaComponent(0.9),
|
|
|
+ fill: palette.inputBackground,
|
|
|
+ primaryText: primaryText,
|
|
|
+ mutedText: mutedText
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
|
|
|
+ let urlMode = sender.selectedSegment == 0
|
|
|
+ joinURLFieldsContainer?.isHidden = urlMode == false
|
|
|
+ joinIDFieldsContainer?.isHidden = urlMode
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func joinMeetingCancelTapped() {
|
|
|
+ joinMeetingWindow?.performClose(nil)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func joinMeetingSubmitTapped() {
|
|
|
+ guard let segment = joinModeSegment else { return }
|
|
|
+ if segment.selectedSegment == 0 {
|
|
|
+ let raw = joinURLField?.stringValue ?? ""
|
|
|
+ guard let url = parseZoomJoinURLFromUserInput(raw) else {
|
|
|
+ showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ openZoomMeetingInDefaultBrowser(url)
|
|
|
+ } else {
|
|
|
+ let idRaw = joinMeetingIDField?.stringValue ?? ""
|
|
|
+ let digits = idRaw.filter(\.isNumber)
|
|
|
+ guard digits.isEmpty == false else {
|
|
|
+ showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ let pass = joinPasscodeField?.stringValue ?? ""
|
|
|
+ guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
|
|
|
+ showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ openZoomMeetingInDefaultBrowser(url)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
|
|
|
+ private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
|
|
|
+ guard meetingIdDigits.isEmpty == false else { return nil }
|
|
|
+ var components = URLComponents()
|
|
|
+ components.scheme = "https"
|
|
|
+ components.host = "zoom.us"
|
|
|
+ components.path = "/wc/join/\(meetingIdDigits)"
|
|
|
+ let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if trimmed.isEmpty == false {
|
|
|
+ components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
|
|
|
+ }
|
|
|
+ return components.url
|
|
|
+ }
|
|
|
+
|
|
|
+ private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
|
|
|
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard trimmed.isEmpty == false else { return nil }
|
|
|
+ let normalized: String
|
|
|
+ if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
|
|
|
+ normalized = trimmed
|
|
|
+ } else {
|
|
|
+ normalized = "https://\(trimmed)"
|
|
|
+ }
|
|
|
+ guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
|
|
|
+ let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
|
|
|
+ || host == "zoom.com" || host.hasSuffix(".zoom.com")
|
|
|
+ guard isZoom else { return nil }
|
|
|
+ return url
|
|
|
+ }
|
|
|
+
|
|
|
+ private func openZoomMeetingInDefaultBrowser(_ url: URL) {
|
|
|
+ let opened = NSWorkspace.shared.open(url)
|
|
|
+ if opened {
|
|
|
+ joinMeetingWindow?.performClose(nil)
|
|
|
+ } else {
|
|
|
+ showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resetJoinMeetingPanelReferences() {
|
|
|
+ joinMeetingWindow = nil
|
|
|
+ joinURLField = nil
|
|
|
+ joinMeetingIDField = nil
|
|
|
+ joinPasscodeField = nil
|
|
|
+ joinURLFieldsContainer = nil
|
|
|
+ joinIDFieldsContainer = nil
|
|
|
+ joinModeSegment = nil
|
|
|
+ }
|
|
|
+
|
|
|
@objc private func logoutTapped() {
|
|
|
meetingsRefreshTimer?.invalidate()
|
|
|
meetingsRefreshTimer = nil
|
|
|
@@ -2288,7 +2590,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let actions = NSStackView(views: [
|
|
|
makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
|
|
|
- makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
|
|
|
+ makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
|
|
|
makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
|
|
|
])
|
|
|
actions.orientation = .horizontal
|
|
|
@@ -3105,6 +3407,13 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+extension ViewController: NSWindowDelegate {
|
|
|
+ func windowWillClose(_ notification: Notification) {
|
|
|
+ guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
|
|
|
+ resetJoinMeetingPanelReferences()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
private extension Array {
|
|
|
subscript(safe index: Int) -> Element? {
|
|
|
guard index >= 0, index < count else { return nil }
|
|
|
@@ -3112,6 +3421,156 @@ private extension Array {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
|
|
|
+private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
|
|
|
+ private func lineHeight() -> CGFloat {
|
|
|
+ guard let font = font else { return 0 }
|
|
|
+ return ceil(font.ascender - font.descender + font.leading)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
|
|
|
+ let lh = lineHeight()
|
|
|
+ guard lh > 0, h > lh else { return 0 }
|
|
|
+ return max(0, floor((h - lh) / 2))
|
|
|
+ }
|
|
|
+
|
|
|
+ private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
|
|
|
+ let lh = lineHeight()
|
|
|
+ guard lh > 0 else { return cellFrame }
|
|
|
+ let m = verticalMargin(forBoundsHeight: cellFrame.height)
|
|
|
+ var r = cellFrame
|
|
|
+ r.origin.y += m
|
|
|
+ r.size.height = lh
|
|
|
+ return r
|
|
|
+ }
|
|
|
+
|
|
|
+ override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
|
|
|
+ super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
|
|
|
+ }
|
|
|
+
|
|
|
+ override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
|
|
|
+ let text = super.setUpFieldEditorAttributes(textObj)
|
|
|
+ guard let tv = text as? NSTextView else { return text }
|
|
|
+ // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
|
|
|
+ let h = max(controlView?.bounds.height ?? 0, 42)
|
|
|
+ let margin = verticalMargin(forBoundsHeight: h)
|
|
|
+ if margin > 0 {
|
|
|
+ // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
|
|
|
+ tv.textContainerInset = NSSize(width: 0, height: margin)
|
|
|
+ }
|
|
|
+ return text
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
|
|
|
+private final class JoinPanelFieldContainer: NSView {
|
|
|
+ let textField: NSTextField
|
|
|
+ private let normalBorder: NSColor
|
|
|
+ private let focusBorder: NSColor
|
|
|
+ private let fill: NSColor
|
|
|
+ private var beginObserver: NSObjectProtocol?
|
|
|
+ private var endObserver: NSObjectProtocol?
|
|
|
+
|
|
|
+ init(
|
|
|
+ placeholder: String,
|
|
|
+ normalBorder: NSColor,
|
|
|
+ focusBorder: NSColor,
|
|
|
+ fill: NSColor,
|
|
|
+ primaryText: NSColor,
|
|
|
+ mutedText: NSColor
|
|
|
+ ) {
|
|
|
+ self.normalBorder = normalBorder
|
|
|
+ self.focusBorder = focusBorder
|
|
|
+ self.fill = fill
|
|
|
+ textField = NSTextField()
|
|
|
+ textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
|
|
|
+ super.init(frame: .zero)
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ wantsLayer = true
|
|
|
+ // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
|
|
|
+ layer?.masksToBounds = false
|
|
|
+ layer?.cornerRadius = 23
|
|
|
+ layer?.backgroundColor = fill.cgColor
|
|
|
+ applyBorder(focused: false)
|
|
|
+
|
|
|
+ textField.font = .systemFont(ofSize: 14, weight: .regular)
|
|
|
+ textField.textColor = primaryText
|
|
|
+ textField.alignment = .center
|
|
|
+ textField.isEditable = true
|
|
|
+ textField.isSelectable = true
|
|
|
+ textField.refusesFirstResponder = false
|
|
|
+ textField.focusRingType = .none
|
|
|
+ textField.isBordered = false
|
|
|
+ textField.drawsBackground = false
|
|
|
+ textField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ if let cell = textField.cell as? NSTextFieldCell {
|
|
|
+ cell.isBezeled = false
|
|
|
+ cell.drawsBackground = false
|
|
|
+ cell.alignment = .center
|
|
|
+ cell.usesSingleLineMode = true
|
|
|
+ cell.lineBreakMode = .byTruncatingTail
|
|
|
+ let placeholderParagraph = NSMutableParagraphStyle()
|
|
|
+ placeholderParagraph.alignment = .center
|
|
|
+ cell.placeholderAttributedString = NSAttributedString(
|
|
|
+ string: placeholder,
|
|
|
+ attributes: [
|
|
|
+ .foregroundColor: mutedText.withAlphaComponent(0.88),
|
|
|
+ .font: NSFont.systemFont(ofSize: 14, weight: .regular),
|
|
|
+ .paragraphStyle: placeholderParagraph
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ }
|
|
|
+ addSubview(textField)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
|
+ textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
|
+ textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
|
|
|
+ textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
|
|
|
+ heightAnchor.constraint(equalToConstant: 46)
|
|
|
+ ])
|
|
|
+
|
|
|
+ beginObserver = NotificationCenter.default.addObserver(
|
|
|
+ forName: NSControl.textDidBeginEditingNotification,
|
|
|
+ object: textField,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] _ in
|
|
|
+ self?.applyBorder(focused: true)
|
|
|
+ }
|
|
|
+ endObserver = NotificationCenter.default.addObserver(
|
|
|
+ forName: NSControl.textDidEndEditingNotification,
|
|
|
+ object: textField,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] _ in
|
|
|
+ self?.applyBorder(focused: false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
+ fatalError("init(coder:) has not been implemented")
|
|
|
+ }
|
|
|
+
|
|
|
+ deinit {
|
|
|
+ if let beginObserver {
|
|
|
+ NotificationCenter.default.removeObserver(beginObserver)
|
|
|
+ }
|
|
|
+ if let endObserver {
|
|
|
+ NotificationCenter.default.removeObserver(endObserver)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyBorder(focused: Bool) {
|
|
|
+ layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
|
|
|
+ layer?.borderWidth = focused ? 1.5 : 1
|
|
|
+ }
|
|
|
+
|
|
|
+ override func viewDidMoveToWindow() {
|
|
|
+ super.viewDidMoveToWindow()
|
|
|
+ if window == nil {
|
|
|
+ applyBorder(focused: false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
private final class SearchPillTextField: NSTextField {
|
|
|
var onFocusChange: ((Bool) -> Void)?
|
|
|
private(set) var isSearchFocused = false
|