mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
proleStatus bar: proper size, transparent, click through and scrolling
This commit is contained in:
parent
d706cfb8a6
commit
ea49e60d92
@ -22,6 +22,7 @@ brew install mysql-client python@3.14 python-tk@3.14 postgresql@17
|
||||
|
||||
# start k3d cluster
|
||||
k3d cluster create prole-service-cluster -a 2 --registry-create k8s-prole-org-registry:k8s.prole.org:5000 --api-port 10.0.0.205:6443
|
||||
k3d cluster create prole-dev-cluster -a 2 --api-port 0.0.0.0:6443
|
||||
|
||||
# build prole-db Docker image
|
||||
```commandline
|
||||
|
||||
@ -48,6 +48,9 @@ chmod +x build.sh # first time only
|
||||
# Run the built app
|
||||
./build.sh run
|
||||
|
||||
# Debug: run in foreground with verbose logs to stdout
|
||||
./build.sh debug
|
||||
|
||||
# Package into a zip
|
||||
./build.sh package
|
||||
|
||||
@ -59,3 +62,9 @@ Outputs:
|
||||
- The app bundle is placed at `proleStatus/dist/ProleStatus.app`.
|
||||
- The universal build creates a fat binary using `lipo`.
|
||||
- The app is ad‑hoc signed for local running (`codesign -s -`). For distribution/notarization, replace with your signing identity.
|
||||
|
||||
Debug mode notes:
|
||||
- Use `./build.sh debug` to run the app binary directly in the foreground with verbose logs.
|
||||
- The script sets the environment variable `PROLESTATUS_DEBUG=1` which enables detailed lifecycle and network probe logging to stdout.
|
||||
- You will see messages about window placement, menu bar visibility, timer rounds, and per‑host TCP results.
|
||||
- Press Ctrl+C in the terminal to terminate, or use the app’s context menu → Quit.
|
||||
|
||||
@ -6,16 +6,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var serviceChecker: ServiceChecker!
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
dlog("applicationDidFinishLaunching")
|
||||
NSApp.setActivationPolicy(.accessory) // visible menu-less app
|
||||
dlog("Activation policy set to .accessory")
|
||||
|
||||
serviceChecker = ServiceChecker()
|
||||
dlog("ServiceChecker created")
|
||||
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker)
|
||||
dlog("OverlayWindowController created; computing initial frame/visibility")
|
||||
overlayWindowController.showIfMenuBarVisible()
|
||||
|
||||
hotKeyManager = HotKeyManager()
|
||||
hotKeyManager.registerGlobalHotKey(modifiers: [.command, .option, .shift], key: .p) { [weak self] in
|
||||
self?.overlayWindowController.showContextMenu()
|
||||
}
|
||||
dlog("Global hotkey registered (Cmd+Opt+Shift+P)")
|
||||
|
||||
// Observe screen/space/menu bar visibility changes
|
||||
DistributedNotificationCenter.default().addObserver(self, selector: #selector(handleConfigChange), name: NSNotification.Name("com.apple.HIToolbox.inputSourceChanged"), object: nil)
|
||||
@ -23,14 +28,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceChanged), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
|
||||
|
||||
// Check status every 30 seconds
|
||||
dlog("Starting ServiceChecker timer (interval: 30s)")
|
||||
serviceChecker.start(interval: 30)
|
||||
}
|
||||
|
||||
@objc private func handleConfigChange() {
|
||||
dlog("handleConfigChange → recomputeFrameAndVisibility")
|
||||
overlayWindowController.recomputeFrameAndVisibility()
|
||||
}
|
||||
|
||||
@objc private func spaceChanged() {
|
||||
dlog("spaceChanged → recomputeFrameAndVisibility")
|
||||
overlayWindowController.recomputeFrameAndVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
enum Debug {
|
||||
static let isEnabled: Bool = {
|
||||
if let v = ProcessInfo.processInfo.environment["PROLESTATUS_DEBUG"] {
|
||||
return v == "1" || v.lowercased() == "true" || v.lowercased() == "yes"
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
static func log(_ message: @autoclosure () -> String) {
|
||||
guard isEnabled else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
FileHandle.standardOutput.write(("[ProleStatus] " + ts + " " + message() + "\n").data(using: .utf8)!)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func dlog(_ message: @autoclosure () -> String) {
|
||||
Debug.log(message())
|
||||
}
|
||||
@ -2,7 +2,7 @@ import AppKit
|
||||
|
||||
final class OverlayWindowController: NSWindowController {
|
||||
private let statusView = StatusView()
|
||||
private let visualEffect = NSVisualEffectView()
|
||||
// Fully transparent background; no visual effect overlay
|
||||
|
||||
init(serviceChecker: ServiceChecker) {
|
||||
let style: NSWindow.StyleMask = [.borderless]
|
||||
@ -15,30 +15,20 @@ final class OverlayWindowController: NSWindowController {
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
|
||||
// Visual effect that matches the menu bar
|
||||
visualEffect.material = .menu
|
||||
visualEffect.blendingMode = .behindWindow
|
||||
visualEffect.state = .active
|
||||
visualEffect.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let container = NSView()
|
||||
let container = TransparentContainerView()
|
||||
container.wantsLayer = true
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(visualEffect)
|
||||
visualEffect.addSubview(statusView)
|
||||
container.postsFrameChangedNotifications = true
|
||||
container.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
container.addSubview(statusView)
|
||||
|
||||
window.contentView = container
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
visualEffect.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
visualEffect.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
visualEffect.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
visualEffect.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
|
||||
statusView.centerXAnchor.constraint(equalTo: visualEffect.centerXAnchor),
|
||||
statusView.centerYAnchor.constraint(equalTo: visualEffect.centerYAnchor)
|
||||
statusView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
statusView.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
||||
])
|
||||
|
||||
statusView.bindTo(serviceChecker: serviceChecker)
|
||||
@ -52,26 +42,42 @@ final class OverlayWindowController: NSWindowController {
|
||||
func showIfMenuBarVisible() { recomputeFrameAndVisibility() }
|
||||
|
||||
func recomputeFrameAndVisibility() {
|
||||
guard let screen = NSScreen.main else { window?.orderOut(nil); return }
|
||||
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
|
||||
}
|
||||
|
||||
let barHeight = NSStatusBar.system.thickness // expected to be same as menu bar height
|
||||
let y = visible.maxY - barHeight
|
||||
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: barHeight)
|
||||
// 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()
|
||||
// Ensure we remain click-through after any movement/visibility change
|
||||
window?.ignoresMouseEvents = true
|
||||
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())
|
||||
@ -81,6 +87,11 @@ final class OverlayWindowController: NSWindowController {
|
||||
|
||||
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() {
|
||||
@ -88,6 +99,7 @@ final class OverlayWindowController: NSWindowController {
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
dlog("Quit requested via context menu; terminating app")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
@ -100,7 +112,18 @@ final class OverlayWindow: NSWindow {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
ignoresMouseEvents = false // allow hover tracking, but we’ll pass clicks through via hitTest
|
||||
// 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? {
|
||||
// Ensure the entire overlay is click-through
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ final class ServiceChecker {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: interval)
|
||||
t.setEventHandler { [weak self] in self?.refreshAll() }
|
||||
dlog("ServiceChecker: starting timer, interval=\(interval)s")
|
||||
t.resume()
|
||||
timer = t
|
||||
}
|
||||
@ -42,6 +43,7 @@ final class ServiceChecker {
|
||||
@objc private func forceRefresh() { queue.async { self.refreshAll() } }
|
||||
|
||||
private func refreshAll() {
|
||||
dlog("ServiceChecker: begin refresh round")
|
||||
let group = DispatchGroup()
|
||||
|
||||
// clear previous errors before a new round
|
||||
@ -61,35 +63,67 @@ final class ServiceChecker {
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification")
|
||||
NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func tcpPing(host: String, port: UInt16, timeout: TimeInterval = 2.0, completion: @escaping (Bool, Int, String?) -> Void) {
|
||||
dlog("tcpPing: attempting \(host):\(port) timeout=\(timeout)s")
|
||||
let start = DispatchTime.now()
|
||||
let params = NWParameters.tcp
|
||||
params.allowLocalEndpointReuse = true
|
||||
let endpoint = NWEndpoint.hostPort(host: .name(host, nil), port: .init(integerLiteral: port))
|
||||
let conn = NWConnection(to: endpoint, using: params)
|
||||
|
||||
// Ensure completion is invoked exactly once
|
||||
var finished = false
|
||||
func finishOnce(_ ok: Bool, _ ms: Int, _ err: String?, reason: String) {
|
||||
// All state updates and timeout run on `queue` so this is serialized
|
||||
if finished { return }
|
||||
finished = true
|
||||
dlog("tcpPing: finishOnce(\(host):\(port)) reason=\(reason) ok=\(ok) ms=\(ms) err=\(err ?? "nil")")
|
||||
completion(ok, ms, err)
|
||||
}
|
||||
|
||||
// Prepare timeout work item so we can cancel it on success/failure
|
||||
let timeoutWork = DispatchWorkItem { [weak conn] in
|
||||
if finished { return }
|
||||
dlog("tcpPing: timeout reached for \(host):\(port); cancelling connection")
|
||||
conn?.cancel()
|
||||
finishOnce(false, -1, "connection timed out", reason: "timeout")
|
||||
}
|
||||
|
||||
conn.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
|
||||
completion(true, Int(Double(elapsed) / 1_000_000.0), nil)
|
||||
conn.cancel()
|
||||
let ms = Int(Double(elapsed) / 1_000_000.0)
|
||||
dlog("tcpPing: READY \(host):\(port) in \(ms) ms")
|
||||
timeoutWork.cancel()
|
||||
finishOnce(true, ms, nil, reason: "ready")
|
||||
conn.cancel() // will emit .cancelled; ignored due to finished=true
|
||||
case .failed(let error):
|
||||
completion(false, -1, Self.describeNWError(error))
|
||||
let msg = Self.describeNWError(error)
|
||||
dlog("tcpPing: FAILED \(host):\(port) — \(msg)")
|
||||
timeoutWork.cancel()
|
||||
finishOnce(false, -1, msg, reason: "failed")
|
||||
conn.cancel()
|
||||
case .cancelled:
|
||||
// If cancelled before ready, likely timed out
|
||||
completion(false, -1, "connection cancelled (possible timeout)")
|
||||
default: break
|
||||
// If we already finished (e.g., due to .ready), this is expected; ignore.
|
||||
if finished {
|
||||
dlog("tcpPing: CANCELLED \(host):\(port) after finish — ignoring")
|
||||
} else {
|
||||
// Cancel without prior .ready/.failed implies timeout or external cancel
|
||||
finishOnce(false, -1, "connection cancelled (possible timeout)", reason: "cancelled-before-finish")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.start(queue: queue)
|
||||
// Timeout
|
||||
queue.asyncAfter(deadline: .now() + timeout) {
|
||||
conn.cancel()
|
||||
}
|
||||
// Schedule timeout on the same queue
|
||||
queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
|
||||
}
|
||||
|
||||
// Tooltips
|
||||
|
||||
@ -5,7 +5,7 @@ final class StatusView: NSView {
|
||||
private let light2 = TrafficLight()
|
||||
private let light3 = TrafficLight()
|
||||
private let timestampLabel: NSTextField = {
|
||||
let tf = NSTextField(labelWithString: "--:--:--")
|
||||
let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00")
|
||||
tf.textColor = .secondaryLabelColor
|
||||
tf.alignment = .left
|
||||
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
|
||||
@ -13,13 +13,15 @@ final class StatusView: NSView {
|
||||
tf.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
return tf
|
||||
}()
|
||||
private let marquee = MarqueeView()
|
||||
|
||||
private var checker: ServiceChecker?
|
||||
private let timeFormatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.locale = .current
|
||||
df.timeZone = .current
|
||||
df.dateFormat = "HH:mm:ss"
|
||||
// Include full date and timezone offset (e.g., 2025-11-17 20:31:05 -08:00)
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ"
|
||||
return df
|
||||
}()
|
||||
private let fullFormatter: DateFormatter = {
|
||||
@ -36,7 +38,7 @@ final class StatusView: NSView {
|
||||
wantsLayer = true
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let stack = NSStackView(views: [light1, light2, light3, timestampLabel])
|
||||
let stack = NSStackView(views: [light1, light2, light3, timestampLabel, marquee])
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.distribution = .equalSpacing
|
||||
@ -51,6 +53,9 @@ final class StatusView: NSView {
|
||||
|
||||
// Tracking for hover tooltips
|
||||
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
|
||||
|
||||
// Configure marquee width to be ~64 monospace characters
|
||||
configureMarqueeWidth()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
@ -85,6 +90,11 @@ final class StatusView: NSView {
|
||||
let now = Date()
|
||||
timestampLabel.stringValue = timeFormatter.string(from: now)
|
||||
timestampLabel.toolTip = "Last updated: " + fullFormatter.string(from: now)
|
||||
|
||||
// Update marquee text with status summary and ensure it scrolls
|
||||
let msg = aggregateStatusSummary()
|
||||
marquee.setText(msg)
|
||||
marquee.toolTip = msg
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
@ -97,6 +107,162 @@ final class StatusView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MarqueeView
|
||||
final class MarqueeView: NSView {
|
||||
private let scrollClip = NSView()
|
||||
private let label1 = NSTextField(labelWithString: "")
|
||||
private let label2 = NSTextField(labelWithString: "")
|
||||
private var timer: Timer?
|
||||
// Slow down by one third: 60 → 40 pts/sec
|
||||
private var speedPointsPerSec: CGFloat = 40.0 // horizontal speed
|
||||
private var lastTick: TimeInterval = CACurrentMediaTime()
|
||||
private var currentText: String = ""
|
||||
|
||||
// Public width constraint can be set by container; we keep hugging low so it expands to fixed width
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
wantsLayer = false
|
||||
|
||||
scrollClip.wantsLayer = false
|
||||
scrollClip.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(scrollClip)
|
||||
|
||||
// Configure labels
|
||||
for lbl in [label1, label2] {
|
||||
lbl.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
|
||||
lbl.textColor = .secondaryLabelColor
|
||||
lbl.alignment = .left
|
||||
lbl.backgroundColor = .clear
|
||||
lbl.isBezeled = false
|
||||
lbl.drawsBackground = false
|
||||
lbl.lineBreakMode = .byClipping
|
||||
lbl.translatesAutoresizingMaskIntoConstraints = true // we will manage frames manually
|
||||
}
|
||||
|
||||
scrollClip.addSubview(label1)
|
||||
scrollClip.addSubview(label2)
|
||||
|
||||
// Clip to bounds so text scrolls inside
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = true
|
||||
|
||||
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollClip.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scrollClip.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scrollClip.topAnchor.constraint(equalTo: topAnchor),
|
||||
scrollClip.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if window != nil { start() } else { stop() }
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
layoutLabelsIfNeeded()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil } // click-through
|
||||
|
||||
func setText(_ text: String) {
|
||||
if text == currentText { return }
|
||||
currentText = text
|
||||
label1.stringValue = text + String(repeating: " ", count: 8)
|
||||
label2.stringValue = label1.stringValue
|
||||
layoutLabelsIfNeeded(resetOffset: true)
|
||||
}
|
||||
|
||||
private func layoutLabelsIfNeeded(resetOffset: Bool = false) {
|
||||
let h = bounds.height
|
||||
let y = (h - intrinsicLineHeight())/2.0
|
||||
let size = label1.intrinsicContentSize
|
||||
let w = size.width
|
||||
// Place labels back-to-back for seamless loop
|
||||
if resetOffset {
|
||||
label1.frame = NSRect(x: 0, y: y, width: w, height: size.height)
|
||||
label2.frame = NSRect(x: w, y: y, width: w, height: size.height)
|
||||
} else if label1.frame.size.width == 0 || label2.frame.size.width == 0 {
|
||||
label1.frame = NSRect(x: label1.frame.origin.x, y: y, width: w, height: size.height)
|
||||
label2.frame = NSRect(x: label2.frame.origin.x, y: y, width: w, height: size.height)
|
||||
} else {
|
||||
// Keep vertically centered
|
||||
label1.frame.origin.y = y
|
||||
label2.frame.origin.y = y
|
||||
}
|
||||
}
|
||||
|
||||
private func intrinsicLineHeight() -> CGFloat {
|
||||
let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
|
||||
return ceil(f.ascender - f.descender)
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = CACurrentMediaTime()
|
||||
let dt = now - lastTick
|
||||
lastTick = now
|
||||
let dx = CGFloat(dt) * speedPointsPerSec
|
||||
|
||||
// Move both labels left by dx
|
||||
label1.frame.origin.x -= dx
|
||||
label2.frame.origin.x -= dx
|
||||
|
||||
// When a label fully leaves on the left, move it to the right of the other
|
||||
let totalW = label1.frame.width
|
||||
if label1.frame.maxX <= 0 {
|
||||
label1.frame.origin.x = label2.frame.maxX
|
||||
}
|
||||
if label2.frame.maxX <= 0 {
|
||||
label2.frame.origin.x = label1.frame.maxX
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
stop()
|
||||
lastTick = CACurrentMediaTime()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer!, forMode: .common)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
deinit { stop() }
|
||||
}
|
||||
|
||||
// Utility to compute width for N monospace characters
|
||||
private func widthForMonospaceCharacters(_ count: Int, font: NSFont) -> CGFloat {
|
||||
let sample = String(repeating: "0", count: max(1, count))
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||
let w = (sample as NSString).size(withAttributes: attrs).width
|
||||
return ceil(w)
|
||||
}
|
||||
|
||||
private extension StatusView {
|
||||
func configureMarqueeWidth() {
|
||||
// Match font with timestamp for visual cohesion
|
||||
let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
|
||||
marquee.heightAnchor.constraint(equalToConstant: ceil(font.capHeight * 1.8)).isActive = true
|
||||
let width = widthForMonospaceCharacters(64, font: font)
|
||||
let wConstraint = marquee.widthAnchor.constraint(equalToConstant: width)
|
||||
wConstraint.priority = .required
|
||||
wConstraint.isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
final class TrafficLight: NSView {
|
||||
enum State { case green, yellow, red }
|
||||
var state: State = .red { didSet { needsDisplay = true } }
|
||||
|
||||
@ -2,5 +2,6 @@ import AppKit
|
||||
|
||||
_ = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
dlog("Process started. Setting application delegate and running app loop…")
|
||||
NSApplication.shared.delegate = delegate
|
||||
NSApplication.shared.run()
|
||||
|
||||
@ -30,6 +30,7 @@ Commands:
|
||||
build-universal Build a universal (arm64+x86_64) ${APP_NAME}.app
|
||||
clean Remove build artifacts
|
||||
run Build (if needed) and run the app
|
||||
debug Build (if needed) and run in foreground with verbose logs
|
||||
package Zip the built app into dist/${APP_NAME}.zip
|
||||
|
||||
Options (for build):
|
||||
@ -178,6 +179,24 @@ case "$cmd" in
|
||||
build_universal ;;
|
||||
run)
|
||||
run_app ;;
|
||||
debug)
|
||||
# Build if needed, then run the binary directly in the foreground
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
"$0" build
|
||||
fi
|
||||
BIN="$MACOS_DIR/${APP_NAME}"
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "[debug] error: binary not found at $BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[debug] Running ${APP_NAME} in foreground with verbose logs"
|
||||
echo "[debug] Press Ctrl+C to terminate. To quit from UI, use the context menu or Cmd+Q."
|
||||
export PROLESTATUS_DEBUG=1
|
||||
"$BIN"
|
||||
status=$?
|
||||
echo "[debug] ${APP_NAME} exited with status ${status}"
|
||||
exit $status
|
||||
;;
|
||||
package)
|
||||
package_app ;;
|
||||
*)
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user