545 lines
22 KiB
Swift
545 lines
22 KiB
Swift
import AppKit
|
|||
|
|
import Combine
|
||
|
|
import SwiftUI
|
||
|
|
|
||
|
|
/**
|
||
|
|
The viewer's one window: the selected installation's dashboard and — when
|
||
|
|
there is more than one — a slim tab bar of their names.
|
||
|
|
|
||
|
|
In a window the bar floats over the top of the dashboard, just under the
|
||
|
|
title bar: it comes out while the pointer is on the title bar and tucks away
|
||
|
|
after. It is deliberately not part of the title bar — anything there makes
|
||
|
|
WebKit push the whole page down to clear it, and a dashboard jumping every
|
||
|
|
time the bar came and went would be worse than no bar at all.
|
||
|
|
|
||
|
|
In full screen the bar *is* part of the title bar (a titlebar accessory), so
|
||
|
|
it comes down with the menu bar like any Mac app's toolbar; there the title
|
||
|
|
bar lives in a window of its own and the page stays put.
|
||
|
|
*/
|
||
|
|
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
|
||
|
|
|
||
|
|
static let tabBarHeight: CGFloat = 32
|
||
|
|
|
||
|
|
/// The standard title bar's height: where the dashboard starts.
|
||
|
|
private static let titlebarHeight: CGFloat = {
|
||
|
|
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
|
||
|
|
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
|
||
|
|
}()
|
||
|
|
|
||
|
|
/// Empty state's "Add Connection…".
|
||
|
|
var onAddConnection: () -> Void = {}
|
||
|
|
|
||
|
|
private let store = ConnectionStore.shared
|
||
|
|
private let settings = ViewerSettings.shared
|
||
|
|
private var pages: [UUID: DashboardPage] = [:]
|
||
|
|
private var order: [UUID] = []
|
||
|
|
private var selection: UUID?
|
||
|
|
|
||
|
|
private let content = ViewerContentView()
|
||
|
|
private let pageArea = NSView()
|
||
|
|
private var pageAreaTop: NSLayoutConstraint!
|
||
|
|
private var emptyState: NSView!
|
||
|
|
|
||
|
|
private let tabModel = TabBarModel()
|
||
|
|
private var tabOverlay: TabBarOverlay!
|
||
|
|
private var tabOverlayTop: NSLayoutConstraint!
|
||
|
|
private let tabAccessory = NSTitlebarAccessoryViewController()
|
||
|
|
private var accessoryAttached = false
|
||
|
|
private var tabBarShown = false
|
||
|
|
private var hideTimer: Timer?
|
||
|
|
private var inFullScreen = false
|
||
|
|
private var leaving = false
|
||
|
|
|
||
|
|
private var awakeActivity: NSObjectProtocol?
|
||
|
|
private var keyMonitor: Any?
|
||
|
|
private var cancellables = Set<AnyCancellable>()
|
||
|
|
|
||
|
|
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
|
||
|
|
private var hasTabs: Bool { order.count > 1 }
|
||
|
|
|
||
|
|
convenience init() {
|
||
|
|
let window = NSWindow(
|
||
|
|
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
|
||
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||
|
|
backing: .buffered, defer: false)
|
||
|
|
window.title = "ProdDash Viewer"
|
||
|
|
window.minSize = NSSize(width: 640, height: 420)
|
||
|
|
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
|
||
|
|
window.collectionBehavior.insert(.fullScreenPrimary)
|
||
|
|
window.isRestorable = false // frame and full screen are kept below instead
|
||
|
|
window.center()
|
||
|
|
window.setFrameAutosaveName("ProdDashViewerWindow")
|
||
|
|
self.init(window: window)
|
||
|
|
window.delegate = self
|
||
|
|
build(in: window)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func build(in window: NSWindow) {
|
||
|
|
// The content runs under the title bar so the pointer can be watched
|
||
|
|
// there; the dashboard itself starts below it.
|
||
|
|
window.contentView = content
|
||
|
|
|
||
|
|
pageArea.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
content.addSubview(pageArea)
|
||
|
|
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
|
||
|
|
|
||
|
|
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
|
||
|
|
empty.sizingOptions = [] // sized by the window, never the other way round
|
||
|
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
content.addSubview(empty)
|
||
|
|
emptyState = empty
|
||
|
|
|
||
|
|
tabOverlay = TabBarOverlay(model: tabModel)
|
||
|
|
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
tabOverlay.isHidden = true
|
||
|
|
tabOverlay.alphaValue = 0
|
||
|
|
content.addSubview(tabOverlay)
|
||
|
|
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
|
||
|
|
|
||
|
|
NSLayoutConstraint.activate([
|
||
|
|
pageAreaTop,
|
||
|
|
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||
|
|
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||
|
|
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||
|
|
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||
|
|
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||
|
|
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||
|
|
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||
|
|
tabOverlayTop,
|
||
|
|
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||
|
|
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||
|
|
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
|
||
|
|
])
|
||
|
|
|
||
|
|
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
|
||
|
|
bar.sizingOptions = []
|
||
|
|
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
|
||
|
|
tabAccessory.view = bar
|
||
|
|
tabAccessory.layoutAttribute = .bottom
|
||
|
|
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
|
||
|
|
tabModel.onSelect = { [weak self] id in self?.select(id) }
|
||
|
|
|
||
|
|
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
|
||
|
|
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
|
||
|
|
|
||
|
|
selection = settings.selectedConnection
|
||
|
|
// @Published sends before it stores, so use the value it sends.
|
||
|
|
store.$connections
|
||
|
|
.sink { [weak self] connections in self?.sync(connections) }
|
||
|
|
.store(in: &cancellables)
|
||
|
|
settings.$keepDisplayAwake
|
||
|
|
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
|
||
|
|
.store(in: &cancellables)
|
||
|
|
|
||
|
|
installKeyboardSwitching()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Connections and tabs
|
||
|
|
|
||
|
|
private func sync(_ connections: [Connection]) {
|
||
|
|
let ids = Set(connections.map(\.id))
|
||
|
|
let hadTabs = hasTabs
|
||
|
|
for (id, page) in pages where !ids.contains(id) {
|
||
|
|
page.close()
|
||
|
|
pages[id] = nil
|
||
|
|
}
|
||
|
|
for connection in connections {
|
||
|
|
if let page = pages[connection.id] {
|
||
|
|
page.update(connection)
|
||
|
|
} else {
|
||
|
|
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
|
||
|
|
page.onChange = { [weak self, weak page] in
|
||
|
|
if let page { self?.pageChanged(page) }
|
||
|
|
}
|
||
|
|
pages[connection.id] = page
|
||
|
|
// A dashboard behind the one on screen still lays itself out
|
||
|
|
// at the window's size, not at nothing.
|
||
|
|
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
|
||
|
|
height: max(content.bounds.height - pageAreaTop.constant, 0))
|
||
|
|
page.view.layoutSubtreeIfNeeded()
|
||
|
|
page.load()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
order = connections.map(\.id)
|
||
|
|
if selection.map(ids.contains) != true { selection = order.first }
|
||
|
|
showSelection()
|
||
|
|
refreshTabs()
|
||
|
|
placeTabBar()
|
||
|
|
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
|
||
|
|
updateDisplayAwake(settings.keepDisplayAwake)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func pageChanged(_ page: DashboardPage) {
|
||
|
|
refreshTabs()
|
||
|
|
if page === currentPage {
|
||
|
|
updateTitle()
|
||
|
|
// A new address means a new web view: put it on screen.
|
||
|
|
if page.view.superview !== pageArea { showSelection() }
|
||
|
|
// The web view can't take the keyboard until it's shown something.
|
||
|
|
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
|
||
|
|
window?.makeFirstResponder(page.webView)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func showSelection() {
|
||
|
|
let page = currentPage
|
||
|
|
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
|
||
|
|
if let page, page.view.superview !== pageArea {
|
||
|
|
page.view.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
pageArea.addSubview(page.view)
|
||
|
|
NSLayoutConstraint.activate([
|
||
|
|
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||
|
|
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||
|
|
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||
|
|
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||
|
|
])
|
||
|
|
}
|
||
|
|
emptyState.isHidden = page != nil
|
||
|
|
if let page { window?.makeFirstResponder(page.webView) }
|
||
|
|
settings.selectedConnection = selection
|
||
|
|
updateTitle()
|
||
|
|
}
|
||
|
|
|
||
|
|
private func updateTitle() {
|
||
|
|
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
|
||
|
|
}
|
||
|
|
|
||
|
|
private func refreshTabs() {
|
||
|
|
tabModel.tabs = order.compactMap { id in
|
||
|
|
pages[id].map {
|
||
|
|
TabBarModel.Tab(id: id, title: $0.connection.displayName,
|
||
|
|
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
tabModel.selection = selection
|
||
|
|
}
|
||
|
|
|
||
|
|
func select(_ id: UUID) {
|
||
|
|
guard pages[id] != nil else { return }
|
||
|
|
if id != selection {
|
||
|
|
selection = id
|
||
|
|
showSelection()
|
||
|
|
refreshTabs()
|
||
|
|
}
|
||
|
|
flashTabBar(for: 2)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func select(index: Int) {
|
||
|
|
guard order.indices.contains(index) else { return }
|
||
|
|
select(order[index])
|
||
|
|
}
|
||
|
|
|
||
|
|
private func step(_ delta: Int) {
|
||
|
|
guard !order.isEmpty else { return }
|
||
|
|
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
|
||
|
|
select(index: (index + delta + order.count) % order.count)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
|
||
|
|
/// the menus stay as short as the viewer is.
|
||
|
|
private func installKeyboardSwitching() {
|
||
|
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||
|
|
guard let self, event.window === self.window, self.hasTabs else { return event }
|
||
|
|
// Only the keys that are pressed together — Caps Lock doesn't count.
|
||
|
|
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||
|
|
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
|
||
|
|
self.step(mods.contains(.shift) ? -1 : 1)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
|
||
|
|
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return event
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - The tab bar coming and going
|
||
|
|
|
||
|
|
/// Overlay in a window, titlebar accessory in full screen — and neither
|
||
|
|
/// with only one connection. The accessory stays attached whenever there
|
||
|
|
/// are tabs, so it is already in place when AppKit builds the full-screen
|
||
|
|
/// title bar; in a window it is hidden, and hidden it takes no room, so
|
||
|
|
/// the page stays put.
|
||
|
|
private func placeTabBar() {
|
||
|
|
tabAccessory.isHidden = !inFullScreen
|
||
|
|
if hasTabs != accessoryAttached {
|
||
|
|
accessoryAttached = hasTabs
|
||
|
|
if hasTabs {
|
||
|
|
window?.addTitlebarAccessoryViewController(tabAccessory)
|
||
|
|
} else {
|
||
|
|
tabAccessory.removeFromParent()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !hasTabs {
|
||
|
|
hideTimer?.invalidate()
|
||
|
|
setTabBar(shown: false, animated: false)
|
||
|
|
}
|
||
|
|
content.updateTrackingAreas()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Under the title bar in a window; the top of the screen in full screen,
|
||
|
|
/// where it only flashes up after a switch.
|
||
|
|
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
|
||
|
|
/// Slid up by a few points while hidden, so it slides down as it fades in.
|
||
|
|
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
|
||
|
|
|
||
|
|
/// The strip along the top that brings the bar out: the title bar, a
|
||
|
|
/// sliver of the dashboard below it, and the bar itself while it's out.
|
||
|
|
private var revealZoneHeight: CGFloat {
|
||
|
|
guard hasTabs, !inFullScreen else { return 0 }
|
||
|
|
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func pointerInTopZone(_ inside: Bool) {
|
||
|
|
if inside {
|
||
|
|
hideTimer?.invalidate()
|
||
|
|
setTabBar(shown: true)
|
||
|
|
} else {
|
||
|
|
scheduleHide(after: 0.8)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Bring the bar out for a moment — at launch, and after switching, so
|
||
|
|
/// it's clear what changed. In full screen too, where nothing else says
|
||
|
|
/// which installation is on screen.
|
||
|
|
private func flashTabBar(for seconds: TimeInterval) {
|
||
|
|
guard hasTabs else { return }
|
||
|
|
setTabBar(shown: true)
|
||
|
|
if !content.pointerInTopZone { scheduleHide(after: seconds) }
|
||
|
|
}
|
||
|
|
|
||
|
|
private func scheduleHide(after delay: TimeInterval) {
|
||
|
|
hideTimer?.invalidate()
|
||
|
|
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
||
|
|
guard let self, !self.content.pointerInTopZone else { return }
|
||
|
|
self.setTabBar(shown: false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func setTabBar(shown: Bool, animated: Bool = true) {
|
||
|
|
let shown = shown && hasTabs
|
||
|
|
guard shown != tabBarShown else { return }
|
||
|
|
tabBarShown = shown
|
||
|
|
if shown { tabOverlay.isHidden = false }
|
||
|
|
content.layoutSubtreeIfNeeded()
|
||
|
|
NSAnimationContext.runAnimationGroup({ context in
|
||
|
|
context.duration = animated ? 0.18 : 0
|
||
|
|
context.allowsImplicitAnimation = true
|
||
|
|
tabOverlay.animator().alphaValue = shown ? 1 : 0
|
||
|
|
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
|
||
|
|
content.layoutSubtreeIfNeeded()
|
||
|
|
}, completionHandler: { [weak self] in
|
||
|
|
guard let self, !self.tabBarShown else { return }
|
||
|
|
self.tabOverlay.isHidden = true
|
||
|
|
})
|
||
|
|
content.updateTrackingAreas()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Full screen
|
||
|
|
|
||
|
|
func windowWillEnterFullScreen(_ notification: Notification) {
|
||
|
|
settleOverlay()
|
||
|
|
inFullScreen = true
|
||
|
|
pageAreaTop.constant = 0
|
||
|
|
placeTabBar()
|
||
|
|
tabOverlayTop.constant = hiddenOverlayTop
|
||
|
|
}
|
||
|
|
|
||
|
|
func windowDidEnterFullScreen(_ notification: Notification) {
|
||
|
|
if !leaving { settings.reopenInFullScreen = true }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The accessory hides before the window is a window again — showing in
|
||
|
|
/// a window's title bar, it would push the dashboard down.
|
||
|
|
func windowWillExitFullScreen(_ notification: Notification) {
|
||
|
|
settleOverlay()
|
||
|
|
inFullScreen = false
|
||
|
|
pageAreaTop.constant = Self.titlebarHeight
|
||
|
|
placeTabBar()
|
||
|
|
tabOverlayTop.constant = hiddenOverlayTop
|
||
|
|
}
|
||
|
|
|
||
|
|
/// No overlay riding through the full-screen animation.
|
||
|
|
private func settleOverlay() {
|
||
|
|
hideTimer?.invalidate()
|
||
|
|
setTabBar(shown: false, animated: false)
|
||
|
|
}
|
||
|
|
|
||
|
|
func windowDidExitFullScreen(_ notification: Notification) {
|
||
|
|
if !leaving { settings.reopenInFullScreen = false }
|
||
|
|
content.updateTrackingAreas()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Called on the way out, before AppKit takes the window out of full
|
||
|
|
/// screen on its own account.
|
||
|
|
func rememberFullScreen() {
|
||
|
|
guard !leaving, let window else { return }
|
||
|
|
leaving = true
|
||
|
|
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Window
|
||
|
|
|
||
|
|
/// Before AppKit takes a full-screen window out of full screen to close it.
|
||
|
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||
|
|
rememberFullScreen()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
/// One window is the whole app: closing it quits, as in System Settings.
|
||
|
|
func windowWillClose(_ notification: Notification) {
|
||
|
|
rememberFullScreen()
|
||
|
|
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||
|
|
}
|
||
|
|
|
||
|
|
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||
|
|
updateDisplayAwake(settings.keepDisplayAwake)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Hold the display awake while a dashboard is actually on screen — not
|
||
|
|
/// while the window is minimised, hidden or covered.
|
||
|
|
private func updateDisplayAwake(_ keep: Bool) {
|
||
|
|
let visible = window?.occlusionState.contains(.visible) == true
|
||
|
|
let wanted = keep && visible && !order.isEmpty
|
||
|
|
if wanted, awakeActivity == nil {
|
||
|
|
awakeActivity = ProcessInfo.processInfo.beginActivity(
|
||
|
|
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
|
||
|
|
} else if !wanted, let activity = awakeActivity {
|
||
|
|
ProcessInfo.processInfo.endActivity(activity)
|
||
|
|
awakeActivity = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - View menu
|
||
|
|
|
||
|
|
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
|
||
|
|
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
|
||
|
|
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
|
||
|
|
|
||
|
|
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||
|
|
switch item.action {
|
||
|
|
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
|
||
|
|
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
|
||
|
|
case #selector(reloadPage(_:)): return currentPage != nil
|
||
|
|
default: return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The tab bar as it shows in a window: the title bar's material, the tabs,
|
||
|
|
/// and a hairline under them, floating over the top of the dashboard.
|
||
|
|
final class TabBarOverlay: NSView {
|
||
|
|
|
||
|
|
init(model: TabBarModel) {
|
||
|
|
super.init(frame: .zero)
|
||
|
|
let material = NSVisualEffectView()
|
||
|
|
material.material = .titlebar
|
||
|
|
material.blendingMode = .withinWindow
|
||
|
|
material.state = .followsWindowActiveState
|
||
|
|
let tabs = TabBarHostingView(rootView: TabBar(model: model))
|
||
|
|
tabs.sizingOptions = []
|
||
|
|
let rule = Hairline()
|
||
|
|
for view in [material, tabs, rule] {
|
||
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||
|
|
addSubview(view)
|
||
|
|
}
|
||
|
|
NSLayoutConstraint.activate([
|
||
|
|
material.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
|
|
material.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
|
|
material.topAnchor.constraint(equalTo: topAnchor),
|
||
|
|
material.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
|
|
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
|
|
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
|
|
tabs.topAnchor.constraint(equalTo: topAnchor),
|
||
|
|
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
|
||
|
|
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
|
|
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
|
|
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
|
|
rule.heightAnchor.constraint(equalToConstant: 1),
|
||
|
|
])
|
||
|
|
}
|
||
|
|
|
||
|
|
required init?(coder: NSCoder) { fatalError("not used") }
|
||
|
|
|
||
|
|
/// The title bar's separator line. (An NSBox separator would do, but one
|
||
|
|
/// laid out from a zero frame decides it's vertical and pins the window
|
||
|
|
/// to a point wide.)
|
||
|
|
private final class Hairline: NSView {
|
||
|
|
override func draw(_ dirtyRect: NSRect) {
|
||
|
|
NSColor.separatorColor.setFill()
|
||
|
|
bounds.fill()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The window's content view. It watches the strip along the top of the
|
||
|
|
/// window — which, with the content drawn under the title bar, includes the
|
||
|
|
/// title bar — and says when the pointer comes and goes. Tracking stays on
|
||
|
|
/// while another app is in front, so the bar answers the pointer even then.
|
||
|
|
final class ViewerContentView: NSView {
|
||
|
|
|
||
|
|
var topZoneHeight: () -> CGFloat = { 0 }
|
||
|
|
var onPointerInTopZone: (Bool) -> Void = { _ in }
|
||
|
|
private var zone: NSTrackingArea?
|
||
|
|
|
||
|
|
private var zoneRect: NSRect {
|
||
|
|
let height = min(topZoneHeight(), bounds.height)
|
||
|
|
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||
|
|
}
|
||
|
|
|
||
|
|
var pointerInTopZone: Bool {
|
||
|
|
guard let window else { return false }
|
||
|
|
let rect = zoneRect
|
||
|
|
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
override func updateTrackingAreas() {
|
||
|
|
super.updateTrackingAreas()
|
||
|
|
if let zone { removeTrackingArea(zone) }
|
||
|
|
zone = nil
|
||
|
|
let rect = zoneRect
|
||
|
|
guard rect.height > 0 else { return }
|
||
|
|
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||
|
|
addTrackingArea(area)
|
||
|
|
zone = area
|
||
|
|
}
|
||
|
|
|
||
|
|
override func mouseEntered(with event: NSEvent) {
|
||
|
|
if event.trackingArea === zone { onPointerInTopZone(true) }
|
||
|
|
}
|
||
|
|
|
||
|
|
override func mouseExited(with event: NSEvent) {
|
||
|
|
if event.trackingArea === zone { onPointerInTopZone(false) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The window before any connection exists.
|
||
|
|
struct EmptyStateView: View {
|
||
|
|
|
||
|
|
let add: () -> Void
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
VStack(spacing: 14) {
|
||
|
|
Image(nsImage: NSApp.applicationIconImage)
|
||
|
|
.resizable()
|
||
|
|
.frame(width: 96, height: 96)
|
||
|
|
Text("No ProdDash Connections")
|
||
|
|
.font(.title2.weight(.semibold))
|
||
|
|
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
|
||
|
|
.multilineTextAlignment(.center)
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
.frame(maxWidth: 380)
|
||
|
|
.fixedSize(horizontal: false, vertical: true)
|
||
|
|
Button("Add Connection…", action: add)
|
||
|
|
.controlSize(.large)
|
||
|
|
.keyboardShortcut(.defaultAction)
|
||
|
|
.padding(.top, 6)
|
||
|
|
}
|
||
|
|
.padding(40)
|
||
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
||
|
|
}
|
||
|
|
}
|