ProdDash Viewer 1.0.0: a Mac app that only shows ProdDash, with a tab per installation
A new Apple-silicon app in viewer/macos (Swift, no dependencies, ~1 MB): one window showing a ProdDash dashboard in WebKit, and nothing else. It runs no server; it points at any ProdDash on the network. - Connections (name + address) in Settings; the add/edit sheet asks /api/modules whether ProdDash answers as you type. No port means 24500. - Two or more connections bring a slim tab bar of their names. In a window it floats over the dashboard and comes out while the pointer is on the title bar; it is not part of the title bar, because anything there makes WebKit push the page down. In full screen it is a titlebar accessory that comes down with the menu bar, and it flashes up after each switch. ⌃Tab / ⌃⇧Tab and ⌘1–9 switch from the keyboard. - Full screen, reopening in full screen if it quit there; window size and the selected tab are kept too. The dashboard's own full-screen button works as well. - Menus: View (Back, Forward, Reload Page, Enter Full Screen), Settings, Edit (copy/paste in the page's text fields), Window, Help (a guide that ships in the app). - Every installation keeps its own live web view. One that can't be reached says why and retries at 2, 4, 8, then every 15 s; a redirect off the installation is reported, not handed to the browser. JS alert, confirm and prompt show as sheets; links to other sites open in the browser. - General settings: open at login (SMAppService), keep the display awake while a dashboard is on screen (default on). build.sh signs with the Developer ID when one is installed and makes a notarised DMG with --notarize, as the launcher's does. It needs Xcode: with only the command line tools the macOS 27 SDK can't expand SwiftUI's @State. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c043834d1e
commit
8d8c0ee0c3
@@ -0,0 +1,544 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user