prole/prole-app/Sources/OverlayWindow.swift
chrisfu 8a6e8738db ProleStatus: app-window default, light splash restored, status bar controls; endpoint config via properties; build + docs
- Default to Application Window mode with full app menu; status bar overlay remains available
- Restore clickable startup Splash Tip (5s) with animated GIF and live counter; use light theme (Aqua)
- Status bar item: monospaced "P" icon; left click shows overlay (status mode) or main window (app mode)
- Overlay: add Maximize button (□) to toggle back to main window; keep click‑through elsewhere
- Global hotkey Cmd+Opt+Shift+P toggles modes; Prole menu item mirrors the same toggle and updates title dynamically
- Application menu (Prole): Show Status Bar / Show Main Window (contextual), Refresh Now, Quit
- Application Window layout: non‑scrolling, vertical 1‑line rows (svc, k3s aggregate, local) with top‑right timestamp; bottom‑left controls (⟳ Refresh, _ Minimize)
- Parameterize endpoints via `prole.properties` (bundled + user override). Replace legacy raspberry with retropie defaults
- ServiceChecker + UI read endpoints from Config loader; tooltips/labels reflect configured hosts/ports
- Build script: generate `Info.plist` with `LSUIElement=false`; bundle resources (`prole-type.gif`, `prole.properties`); ad‑hoc codesign. Universal build supported
- Documentation: rewrite README with technical build/run/config details and operational posture

Files:
- proleStatus/Sources/: AppDelegate.swift, OverlayWindow.swift, StatusView.swift, StatusItemController.swift,
  SplashTipWindowController.swift, MainWindowController.swift, AppStatusView.swift, ServiceChecker.swift, Config.swift
- proleStatus/build.sh
- proleStatus/prole.properties
- proleStatus/README.md

Notes:
- Build verified via `./build.sh build` (arm64). App starts in App Window mode with light theme; menu + hotkey + overlay toggle operate as intended
2025-12-01 21:08:30 -08:00

157 lines
6.7 KiB
Swift

import AppKit
final class OverlayWindowController: NSWindowController {
private let statusView = StatusView()
// Fully transparent background; no visual effect overlay
init(serviceChecker: ServiceChecker) {
let style: NSWindow.StyleMask = [.borderless]
let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false)
super.init(window: window)
window.isReleasedWhenClosed = false
window.level = .floating // below status bar level, above normal windows
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
window.hasShadow = false
window.backgroundColor = .clear
window.isOpaque = false
statusView.translatesAutoresizingMaskIntoConstraints = false
let container = TransparentContainerView()
container.wantsLayer = true
container.translatesAutoresizingMaskIntoConstraints = false
container.postsFrameChangedNotifications = true
container.layer?.backgroundColor = NSColor.clear.cgColor
container.addSubview(statusView)
window.contentView = container
NSLayoutConstraint.activate([
statusView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
statusView.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
statusView.bindTo(serviceChecker: serviceChecker)
// Initial placement
recomputeFrameAndVisibility()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func showIfMenuBarVisible() { recomputeFrameAndVisibility() }
// Show the overlay explicitly, regardless of whether the menu bar is auto-hidden.
// Positions the overlay at the top of the visible frame.
func showOverlayNow() {
guard let screen = NSScreen.main else { return }
let frame = screen.frame
let visible = screen.visibleFrame
let statusThickness = NSStatusBar.system.thickness
let overlayHeight = statusThickness + 6.0
let y = visible.maxY - overlayHeight
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight)
window?.setFrame(rect, display: true)
window?.orderFrontRegardless()
// Allow mouse for our inline controls (refresh/maximize). The view will pass-through elsewhere.
window?.ignoresMouseEvents = false
dlog("Overlay window forced visible at top of visible frame")
}
func hideOverlay() {
window?.orderOut(nil)
}
func recomputeFrameAndVisibility() {
guard let screen = NSScreen.main else {
dlog("No main screen detected; ordering window out")
window?.orderOut(nil); return
}
let frame = screen.frame
let visible = screen.visibleFrame
let menuBarHeight = frame.maxY - visible.maxY // 0 if auto-hidden or full-screen space
if menuBarHeight <= 0.5 { // treat as hidden/no menu bar
dlog("Menu bar not visible (height≈0). Hiding overlay window.")
window?.orderOut(nil)
return
}
// Make the overlay a bit taller for better readability than the status bar thickness
let statusThickness = NSStatusBar.system.thickness
let overlayHeight = statusThickness + 6.0
let y = visible.maxY - overlayHeight
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight)
dlog("Computed overlay frame: x=\(rect.origin.x), y=\(rect.origin.y), w=\(rect.size.width), h=\(rect.size.height) | menuBarHeight=\(menuBarHeight), statusBarThickness=\(statusThickness), overlayHeight=\(overlayHeight)")
window?.setFrame(rect, display: true)
window?.orderFrontRegardless()
// Enable mouse so the inline buttons work; StatusView forwards other clicks through.
window?.ignoresMouseEvents = false
dlog("Overlay window ordered front")
}
func showContextMenu() {
guard let window = window, window.isVisible else { return }
// Temporarily enable mouse interactions so the context menu can be used
let wasIgnoring = window.ignoresMouseEvents
if wasIgnoring {
dlog("Enabling mouse events for context menu interaction")
window.ignoresMouseEvents = false
}
let menu = NSMenu(title: "ProleStatus")
menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "")
menu.addItem(.separator())
menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self
menu.addItem(.separator())
menu.addItem(withTitle: "Quit ProleStatus", action: #selector(quit), keyEquivalent: "q").target = self
let point = NSPoint(x: window.frame.midX, y: window.frame.minY)
menu.popUp(positioning: nil, at: point, in: nil)
// Restore click-through after the menu is dismissed
if wasIgnoring {
dlog("Restoring click-through after context menu")
window.ignoresMouseEvents = true
}
}
@objc private func refreshNow() {
NotificationCenter.default.post(name: ServiceChecker.forceRefreshNotification, object: nil)
}
@objc private func quit() {
dlog("Quit requested via context menu; terminating app")
NSApp.terminate(nil)
}
}
final class OverlayWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
isOpaque = false
backgroundColor = .clear
// Default to fully click-through. We temporarily disable this only while showing the context menu.
ignoresMouseEvents = true
acceptsMouseMovedEvents = true
dlog("OverlayWindow initialized (borderless, transparent, accepts mouse moved events)")
}
}
// Transparent container view that passes through mouse clicks everywhere
// while still allowing mouse-moved events for subviews that add tracking areas.
final class TransparentContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
// Allow interaction only with interactive subviews (e.g., buttons inside StatusView).
// Otherwise, pass clicks through.
if let hit = super.hitTest(point) {
if hit is NSButton { return hit }
// If a subview wants events but isn't a button, also allow it
if hit.acceptsFirstResponder { return hit }
}
return nil
}
}