浏览代码

Add in-app browser toolbar and actions

Introduce a custom top navigation bar for WKWebView windows with common controls (home/pin/menus) and PDF download.

Made-with: Cursor
huzaifahayat12 4 月之前
父节点
当前提交
e552ad55fa
共有 1 个文件被更改,包括 460 次插入2 次删除
  1. 460 2
      google_apps/InAppBrowserWindow.swift

+ 460 - 2
google_apps/InAppBrowserWindow.swift

@@ -27,6 +27,31 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
     private let initialURL: URL
     private var didLoadInitialURL = false
 
+    // Toolbar UI (top navigation bar)
+    private var backButton: NSButton!
+    private var forwardButton: NSButton!
+    private var reloadButton: NSButton!
+    private var pinButton: NSButton!
+    private var homeButton: NSButton!
+    private var gridButton: NSButton!
+    private var openInBrowserButton: NSButton!
+    private var handButton: NSButton!
+    private var downloadButton: NSButton!
+    private var moreButton: NSButton!
+    private var urlField: NSTextField!
+    private var isUpdatingURLField = false
+    private var isPinnedOnTop = false
+    private var isGestureModeEnabled = true
+
+    private func setToggledStyle(_ button: NSButton, isOn: Bool) {
+        button.layer?.backgroundColor = isOn
+            ? NSColor.systemBlue.withAlphaComponent(0.28).cgColor
+            : NSColor.white.withAlphaComponent(0.06).cgColor
+        button.layer?.borderColor = isOn
+            ? NSColor.systemBlue.withAlphaComponent(0.55).cgColor
+            : NSColor.white.withAlphaComponent(0.10).cgColor
+    }
+
     init(initialURL: URL, windowTitle: String?) {
         self.initialURL = initialURL
 
@@ -42,15 +67,242 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
         )
         window.title = windowTitle ?? "Browser"
         window.isReleasedWhenClosed = false
-        window.contentView = webView
+
+        // Build UI: top toolbar + web view underneath.
+        let containerView = NSView(frame: .zero)
+        containerView.wantsLayer = true
+        containerView.layer?.backgroundColor = NSColor.clear.cgColor
+        window.contentView = containerView
+
+        let toolbarHeight: CGFloat = 46
+        let toolbarContainer = NSView()
+        toolbarContainer.translatesAutoresizingMaskIntoConstraints = false
+        toolbarContainer.wantsLayer = true
+        toolbarContainer.layer?.cornerRadius = 14
+        toolbarContainer.layer?.masksToBounds = true
+        containerView.addSubview(toolbarContainer)
+
+        // Blurred background like a modern browser toolbar.
+        let toolbarBlur = NSVisualEffectView()
+        toolbarBlur.material = .hudWindow
+        toolbarBlur.blendingMode = .behindWindow
+        toolbarBlur.state = .active
+        toolbarBlur.translatesAutoresizingMaskIntoConstraints = false
+        toolbarContainer.addSubview(toolbarBlur)
+
+        let toolbarView = NSStackView()
+        toolbarView.orientation = .horizontal
+        toolbarView.alignment = .centerY
+        toolbarView.spacing = 10
+        toolbarView.translatesAutoresizingMaskIntoConstraints = false
+        toolbarContainer.addSubview(toolbarView)
+
+        toolbarBlur.wantsLayer = true
+        toolbarBlur.layer?.cornerRadius = 14
+        toolbarBlur.layer?.masksToBounds = true
+
+        func makeIconButton(symbolName: String, accessibility: String, action: Selector) -> NSButton {
+            let button = NSButton()
+            button.image = NSImage(
+                systemSymbolName: symbolName,
+                accessibilityDescription: accessibility
+            )
+            button.image?.isTemplate = true
+            button.isBordered = false
+            button.contentTintColor = .white
+            button.setButtonType(.momentaryChange)
+            button.action = action
+            button.setButtonType(.momentaryChange)
+
+            // Subtle circular hit target like the screenshot.
+            button.wantsLayer = true
+            button.layer?.cornerRadius = 11
+            button.layer?.masksToBounds = true
+            button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
+            button.layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor
+            button.layer?.borderWidth = 1
+
+            button.translatesAutoresizingMaskIntoConstraints = false
+            button.widthAnchor.constraint(equalToConstant: 22).isActive = true
+            button.heightAnchor.constraint(equalToConstant: 22).isActive = true
+
+            button.toolTip = accessibility
+            return button
+        }
+
+        // Back
+        backButton = makeIconButton(
+            symbolName: "chevron.left",
+            accessibility: "Back",
+            action: #selector(goBack)
+        )
+
+        // Forward
+        forwardButton = makeIconButton(
+            symbolName: "chevron.right",
+            accessibility: "Forward",
+            action: #selector(goForward)
+        )
+
+        // Reload
+        reloadButton = makeIconButton(
+            symbolName: "arrow.clockwise",
+            accessibility: "Reload",
+            action: #selector(reloadPage)
+        )
+
+        // Pin (always on top)
+        pinButton = makeIconButton(
+            symbolName: "pin",
+            accessibility: "Pin (Always on Top)",
+            action: #selector(togglePinOnTop)
+        )
+
+        // Home
+        homeButton = makeIconButton(
+            symbolName: "house",
+            accessibility: "Home",
+            action: #selector(goHome)
+        )
+
+        // Grid (show launcher)
+        gridButton = makeIconButton(
+            symbolName: "square.grid.2x2",
+            accessibility: "Show Launcher",
+            action: #selector(showLauncher)
+        )
+
+        // URL field
+        urlField = NSTextField()
+        urlField.translatesAutoresizingMaskIntoConstraints = false
+        urlField.action = #selector(urlFieldDidSubmit)
+        urlField.font = NSFont.systemFont(ofSize: 13, weight: .regular)
+        urlField.textColor = .white
+        urlField.alignment = .left
+        urlField.isEditable = true
+        urlField.isBordered = false
+        urlField.drawsBackground = false
+        urlField.wantsLayer = true
+        urlField.layer?.backgroundColor = NSColor.clear.cgColor
+
+        // URL "pill" wrapper.
+        let urlWrapperView = NSView()
+        urlWrapperView.translatesAutoresizingMaskIntoConstraints = false
+        urlWrapperView.wantsLayer = true
+        urlWrapperView.layer?.cornerRadius = 10
+        urlWrapperView.layer?.masksToBounds = true
+        urlWrapperView.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
+        urlWrapperView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
+        urlWrapperView.layer?.borderWidth = 1
+
+        // Open in external browser
+        openInBrowserButton = makeIconButton(
+            symbolName: "arrow.up.right.square",
+            accessibility: "Open in Browser",
+            action: #selector(openInBrowser)
+        )
+
+        // Hand tool (toggle navigation gestures)
+        handButton = makeIconButton(
+            symbolName: "hand.raised",
+            accessibility: "Gesture Mode",
+            action: #selector(toggleGestureMode)
+        )
+
+        // Download (save as PDF)
+        downloadButton = makeIconButton(
+            symbolName: "arrow.down.circle",
+            accessibility: "Download PDF",
+            action: #selector(downloadPDF)
+        )
+
+        // More menu
+        moreButton = makeIconButton(
+            symbolName: "ellipsis.vertical",
+            accessibility: "More",
+            action: #selector(showMoreMenu)
+        )
+
+        toolbarView.addArrangedSubview(backButton)
+        toolbarView.addArrangedSubview(forwardButton)
+        toolbarView.addArrangedSubview(reloadButton)
+        toolbarView.addArrangedSubview(urlWrapperView)
+        toolbarView.addArrangedSubview(pinButton)
+        toolbarView.addArrangedSubview(homeButton)
+        toolbarView.addArrangedSubview(gridButton)
+        toolbarView.addArrangedSubview(openInBrowserButton)
+        toolbarView.addArrangedSubview(handButton)
+        toolbarView.addArrangedSubview(downloadButton)
+        toolbarView.addArrangedSubview(moreButton)
+
+        // Give the URL field more space than the icon buttons.
+        urlWrapperView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        urlWrapperView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+
+        urlWrapperView.addSubview(urlField)
+        NSLayoutConstraint.activate([
+            urlField.leadingAnchor.constraint(equalTo: urlWrapperView.leadingAnchor, constant: 12),
+            urlField.trailingAnchor.constraint(equalTo: urlWrapperView.trailingAnchor, constant: -12),
+            urlField.topAnchor.constraint(equalTo: urlWrapperView.topAnchor, constant: 6),
+            urlField.bottomAnchor.constraint(equalTo: urlWrapperView.bottomAnchor, constant: -6),
+            urlWrapperView.heightAnchor.constraint(equalToConstant: 28),
+        ])
+
+        // Web view below toolbar.
+        webView.translatesAutoresizingMaskIntoConstraints = false
+        webView.setValue(false, forKey: "drawsBackground")
+
+        containerView.addSubview(webView)
+
+        NSLayoutConstraint.activate([
+            toolbarContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
+            toolbarContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10),
+            toolbarContainer.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10),
+            toolbarContainer.heightAnchor.constraint(equalToConstant: toolbarHeight),
+
+            toolbarBlur.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor),
+            toolbarBlur.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor),
+            toolbarBlur.topAnchor.constraint(equalTo: toolbarContainer.topAnchor),
+            toolbarBlur.bottomAnchor.constraint(equalTo: toolbarContainer.bottomAnchor),
+
+            toolbarView.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12),
+            toolbarView.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12),
+            toolbarView.topAnchor.constraint(equalTo: toolbarContainer.topAnchor, constant: 6),
+            toolbarView.bottomAnchor.constraint(equalTo: toolbarContainer.bottomAnchor, constant: -6),
+
+            webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+            webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+            webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+            webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor, constant: 0),
+        ])
 
         super.init(window: window)
 
         window.delegate = self
+
+        // Safe to reference `self` only after super.init.
+        backButton.target = self
+        forwardButton.target = self
+        reloadButton.target = self
+        pinButton.target = self
+        homeButton.target = self
+        gridButton.target = self
+        openInBrowserButton.target = self
+        handButton.target = self
+        downloadButton.target = self
+        moreButton.target = self
+        urlField.delegate = self
+        urlField.target = self
+
         webView.uiDelegate = self
         webView.navigationDelegate = self
         webView.allowsBackForwardNavigationGestures = true
-        webView.setValue(false, forKey: "drawsBackground")
+        isGestureModeEnabled = true
+
+        // Initialize toolbar state.
+        setToggledStyle(pinButton, isOn: isPinnedOnTop)
+        setToggledStyle(handButton, isOn: isGestureModeEnabled)
+        updateToolbarState()
     }
 
     @available(*, unavailable)
@@ -68,6 +320,202 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
     func windowWillClose(_ notification: Notification) {
         onClose?()
     }
+
+    @objc private func goBack() {
+        webView.goBack()
+    }
+
+    @objc private func goForward() {
+        webView.goForward()
+    }
+
+    @objc private func reloadPage() {
+        webView.reload()
+    }
+
+    @objc private func openInBrowser() {
+        guard let url = webView.url else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    @objc private func goHome() {
+        webView.load(URLRequest(url: initialURL))
+    }
+
+    @objc private func showLauncher() {
+        // Bring the main app window (launcher) to front.
+        if let main = NSApp.windows.first {
+            main.makeKeyAndOrderFront(nil)
+        }
+        NSApp.activate(ignoringOtherApps: true)
+    }
+
+    @objc private func togglePinOnTop() {
+        isPinnedOnTop.toggle()
+        window?.level = isPinnedOnTop ? .floating : .normal
+        if let button = pinButton {
+            let symbol = isPinnedOnTop ? "pin.fill" : "pin"
+            button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Pin (Always on Top)")
+            button.image?.isTemplate = true
+            setToggledStyle(button, isOn: isPinnedOnTop)
+        }
+    }
+
+    @objc private func toggleGestureMode() {
+        isGestureModeEnabled.toggle()
+        webView.allowsBackForwardNavigationGestures = isGestureModeEnabled
+        if let button = handButton {
+            let symbol = isGestureModeEnabled ? "hand.raised.fill" : "hand.raised"
+            button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Gesture Mode")
+            button.image?.isTemplate = true
+            setToggledStyle(button, isOn: isGestureModeEnabled)
+        }
+    }
+
+    @objc private func downloadPDF() {
+        guard let url = webView.url else { return }
+
+        let host = (url.host?.isEmpty == false) ? (url.host ?? "page") : "page"
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
+        let fileName = "\(host)_\(dateFormatter.string(from: Date())).pdf"
+
+        guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
+            showAlert(title: "Download Failed", message: "Couldn’t find your Downloads folder.")
+            return
+        }
+
+        let targetURL = downloads.appendingPathComponent(fileName)
+
+        if #available(macOS 11.0, *) {
+            webView.createPDF(configuration: WKPDFConfiguration()) { [weak self] result in
+                guard let self else { return }
+                switch result {
+                case .success(let data):
+                    do {
+                        try data.write(to: targetURL, options: .atomic)
+                        self.showAlert(title: "Downloaded", message: "Saved PDF to Downloads:\n\(fileName)")
+                    } catch {
+                        self.showAlert(title: "Download Failed", message: "Couldn’t save the PDF.")
+                    }
+                case .failure:
+                    self.showAlert(title: "Download Failed", message: "Couldn’t generate a PDF for this page.")
+                }
+            }
+        } else {
+            showAlert(title: "Unavailable", message: "PDF download requires macOS 11 or newer.")
+        }
+    }
+
+    @objc private func showMoreMenu() {
+        let menu = NSMenu()
+
+        let copyURL = NSMenuItem(title: "Copy URL", action: #selector(copyCurrentURL), keyEquivalent: "c")
+        copyURL.keyEquivalentModifierMask = [.command]
+        menu.addItem(copyURL)
+
+        menu.addItem(NSMenuItem.separator())
+
+        let reload = NSMenuItem(title: "Reload", action: #selector(reloadPage), keyEquivalent: "r")
+        reload.keyEquivalentModifierMask = [.command]
+        menu.addItem(reload)
+
+        let home = NSMenuItem(title: "Home", action: #selector(goHome), keyEquivalent: "h")
+        home.keyEquivalentModifierMask = [.command]
+        menu.addItem(home)
+
+        let openExternal = NSMenuItem(title: "Open in Default Browser", action: #selector(openInBrowser), keyEquivalent: "o")
+        openExternal.keyEquivalentModifierMask = [.command]
+        menu.addItem(openExternal)
+
+        menu.addItem(NSMenuItem.separator())
+
+        let close = NSMenuItem(title: "Close Window", action: #selector(closeWindowFromMenu), keyEquivalent: "w")
+        close.keyEquivalentModifierMask = [.command]
+        menu.addItem(close)
+
+        menu.items.forEach { $0.target = self }
+
+        let location = NSPoint(x: moreButton.bounds.midX, y: moreButton.bounds.minY - 4)
+        menu.popUp(positioning: nil, at: location, in: moreButton)
+    }
+
+    @objc private func copyCurrentURL() {
+        guard let s = webView.url?.absoluteString, !s.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(s, forType: .string)
+    }
+
+    @objc private func closeWindowFromMenu() {
+        window?.performClose(nil)
+    }
+
+    @objc private func urlFieldDidSubmit() {
+        loadFromURLField()
+    }
+
+    private func loadFromURLField() {
+        let raw = urlField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !raw.isEmpty else { return }
+
+        let normalized: String
+        if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
+            normalized = raw
+        } else {
+            normalized = "https://\(raw)"
+        }
+
+        guard let url = URL(string: normalized) else {
+            revertURLFieldToCurrentWebURL()
+            return
+        }
+
+        // Avoid the URL field update triggering other edits while we load.
+        isUpdatingURLField = true
+        webView.load(URLRequest(url: url))
+        isUpdatingURLField = false
+    }
+
+    private func revertURLFieldToCurrentWebURL() {
+        guard let url = webView.url?.absoluteString else { return }
+        isUpdatingURLField = true
+        urlField.stringValue = url
+        isUpdatingURLField = false
+    }
+
+    private var isURLFieldBeingEdited: Bool {
+        urlField.currentEditor() != nil
+    }
+
+    private func setURLFieldToCurrentWebURL() {
+        guard let url = webView.url?.absoluteString else { return }
+        guard !isURLFieldBeingEdited else { return }
+        guard !isUpdatingURLField else { return }
+
+        isUpdatingURLField = true
+        urlField.stringValue = url
+        isUpdatingURLField = false
+    }
+
+    private func updateToolbarState() {
+        backButton.isEnabled = webView.canGoBack
+        forwardButton.isEnabled = webView.canGoForward
+
+        // Keep reload enabled; it matches typical browser behavior.
+        reloadButton.isEnabled = true
+
+        // Sync URL field and window title.
+        setURLFieldToCurrentWebURL()
+    }
+
+    private func showAlert(title: String, message: String) {
+        let alert = NSAlert()
+        alert.messageText = title
+        alert.informativeText = message
+        alert.addButton(withTitle: "OK")
+        alert.alertStyle = .informational
+        alert.runModal()
+    }
 }
 
 extension InAppBrowserWindowController: WKNavigationDelegate {
@@ -89,6 +537,8 @@ extension InAppBrowserWindowController: WKNavigationDelegate {
         if let host = webView.url?.host, !host.isEmpty {
             window?.title = host
         }
+
+        updateToolbarState()
     }
 
     func webView(
@@ -118,6 +568,14 @@ extension InAppBrowserWindowController: WKNavigationDelegate {
     }
 }
 
+extension InAppBrowserWindowController: NSTextFieldDelegate {
+    func controlTextDidEndEditing(_ obj: Notification) {
+        guard obj.object as? NSTextField === urlField else { return }
+        guard !isUpdatingURLField else { return }
+        loadFromURLField()
+    }
+}
+
 extension InAppBrowserWindowController: WKUIDelegate {
     func webView(
         _ webView: WKWebView,