import AppKit final class StatusView: NSView { private let light1 = TrafficLight() private let light2 = TrafficLight() private let light3 = TrafficLight() private let lightPF = TrafficLight() private let timestampLabel: NSTextField = { 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) tf.setContentHuggingPriority(.required, for: .horizontal) tf.setContentCompressionResistancePriority(.required, for: .horizontal) return tf }() private let marquee = MarqueeView() private let maximizeButton: NSButton = { let b = NSButton(title: "□", target: nil, action: nil) b.bezelStyle = .texturedRounded b.toolTip = "Show Main Window" b.setContentHuggingPriority(.required, for: .horizontal) b.setContentCompressionResistancePriority(.required, for: .horizontal) return b }() private var checker: ServiceChecker? private let timeFormatter: DateFormatter = { let df = DateFormatter() df.locale = .current df.timeZone = .current // 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 = { let df = DateFormatter() df.locale = .current df.timeZone = .current df.dateStyle = .medium df.timeStyle = .medium return df }() override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true translatesAutoresizingMaskIntoConstraints = false let stack = NSStackView(views: [light1, light2, light3, lightPF, timestampLabel, marquee, maximizeButton]) stack.orientation = .horizontal stack.alignment = .centerY stack.distribution = .equalSpacing stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: centerXAnchor), stack.centerYAnchor.constraint(equalTo: centerYAnchor) ]) // Wire button actions maximizeButton.target = self maximizeButton.action = #selector(didTapMaximize) // 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") } override func hitTest(_ point: NSPoint) -> NSView? { // Allow interaction with our inline controls and status lights (for tooltips), // pass other clicks through to avoid blocking the menu bar. if let v = super.hitTest(point) { if v is NSButton { return v } if v is TrafficLight { return v } if let tl = v.toolTip, !tl.isEmpty { return v } } return nil } private var pfManager: PortForwardManager? func bindTo(serviceChecker: ServiceChecker) { self.checker = serviceChecker NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker) updateLights() } func setPortForwardManager(_ manager: PortForwardManager?) { self.pfManager = manager if let m = manager { NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: PortForwardManager.statusDidChangeNotification, object: m) } updateLights() } @objc private func updateLights() { guard let c = checker else { return } // Light 1: svc.prole.org:443 light1.state = c.svcReachable ? .green : .red light1.toolTip = c.tooltipForSvc() // Light 2: aggregate raspberry + pi let count = (c.raspberryReachable ? 1 : 0) + (c.piReachable ? 1 : 0) light2.state = count == 2 ? .green : (count == 1 ? .yellow : .red) light2.toolTip = c.tooltipForAggregateK3s() // Light 3: localhost k3d light3.state = c.localReachable ? .green : .red light3.toolTip = c.tooltipForLocal() // Light 4: port-forward supervision status (Dev) if let m = pfManager { lightPF.state = m.allRunning ? .green : .red lightPF.toolTip = m.allRunning ? "kubectl port-forwards: running" : "kubectl port-forwards: not all running" } else { lightPF.state = .red lightPF.toolTip = "kubectl port-forwards: disabled" } // Timestamp label 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 } func aggregateStatusSummary() -> String { guard let c = checker else { return "No status" } let cfg = Config.shared let svc = c.svcReachable ? "\(cfg.svcHost):\(cfg.svcPort) ✓" : "\(cfg.svcHost):\(cfg.svcPort) ✗" let agg = "k3s: \(cfg.retropieHost) \(c.raspberryReachable ? "✓" : "✗"), \(cfg.piHost) \(c.piReachable ? "✓" : "✗")" let loc = c.localReachable ? "k3d \(cfg.localHost):\(cfg.localPort) ✓" : "k3d \(cfg.localHost):\(cfg.localPort) ✗" return [svc, agg, loc].joined(separator: " • ") } // MARK: - Button actions @objc private func didTapMaximize() { NotificationCenter.default.post(name: .toggleMode, object: nil) } } // 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 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 } } // MARK: - Notification bridge extension Notification.Name { static let showMainWindow = Notification.Name("ProleStatus.showMainWindow") static let toggleMode = Notification.Name("ProleStatus.toggleMode") } final class TrafficLight: NSView { enum State { case green, yellow, red } var state: State = .red { didSet { needsDisplay = true } } override var intrinsicContentSize: NSSize { NSSize(width: 14, height: 14) } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) let rect = bounds.insetBy(dx: 1, dy: 1) let path = NSBezierPath(ovalIn: rect) let color: NSColor switch state { case .green: color = NSColor.systemGreen case .yellow: color = NSColor.systemYellow case .red: color = NSColor.systemRed } color.setFill() path.fill() // subtle ring for better contrast against menu material NSColor.black.withAlphaComponent(0.12).setStroke() path.lineWidth = 1 path.stroke() } }