prole/prole-tools-app/Sources/MainWindowController.swift

318 lines
14 KiB
Swift

import AppKit
// MainWindowController builds the regular window you can move/resize.
// Redesigned per requirements: clear the canvas and show the output of
// etc/init-port-forward.sh in a themed window. Includes:
// - Refresh (manual trigger)
// - A refresh interval selector in the upper-right (10s, 30s [default], 1min, 5min)
// - _ Minimize to Status Bar (switches to overlay mode)
final class MainWindowController: NSWindowController {
struct Actions {
let onRefresh: () -> Void
let onMinimizeToStatusBar: () -> Void
}
// Scrollable, monospaced text view to display script output
private let outputTextView: NSTextView = {
let tv = NSTextView()
tv.isEditable = false
tv.isSelectable = true
tv.isRichText = false // plain text; ensure standard copy works cleanly
tv.usesFindBar = true
// Start slightly smaller than system size; will auto-fit to window afterwards
tv.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
tv.textColor = .labelColor
tv.backgroundColor = .textBackgroundColor
// Important: when used as NSScrollView.documentView, the text view should
// use frame-based resizing rather than Auto Layout constraints. Enable
// autoresizing mask translation so it grows with the scroll content.
tv.translatesAutoresizingMaskIntoConstraints = true
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.minSize = NSSize(width: 0, height: 0)
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.textContainerInset = NSSize(width: 6, height: 8)
if let tc = tv.textContainer {
tc.widthTracksTextView = true
tc.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
}
// Context menu with basic commands routed to first responder
let m = NSMenu(title: "Context")
let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
copyItem.keyEquivalentModifierMask = [.command]
copyItem.target = nil // route to first responder
m.addItem(copyItem)
m.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
tv.menu = m
return tv
}()
private let scrollView: NSScrollView = {
let sv = NSScrollView()
sv.hasVerticalScroller = true
sv.hasHorizontalScroller = false
sv.autohidesScrollers = true
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
private let intervalPopup: NSPopUpButton = {
let p = NSPopUpButton(frame: .zero, pullsDown: false)
p.translatesAutoresizingMaskIntoConstraints = false
p.addItems(withTitles: ["10s", "30s", "1 min", "5 min"])
p.selectItem(withTitle: "30s")
p.toolTip = "Auto-refresh interval"
return p
}()
private var refreshTimer: Timer?
private let actions: Actions
init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) {
// Initialize stored properties before calling super.init
self.actions = actions
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
// Adjusted default size: width -33%, height -25% from previous
let initialRect = NSRect(x: 0, y: 0, width: 965, height: 630)
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
super.init(window: window)
window.isReleasedWhenClosed = false
window.title = "Prole Status — ⌘⌥⇧P to toggle"
window.level = .normal
window.collectionBehavior = [.canJoinAllSpaces]
window.appearance = NSAppearance(named: .aqua)
window.delegate = self
// A plain NSView acts as the content container
let content = NSView()
content.translatesAutoresizingMaskIntoConstraints = false
window.contentView = content
// Header label to avoid title bar overlap and give more context
let headerLabel: NSTextField = {
let tf = NSTextField(labelWithString: "Prole — System Status")
tf.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
tf.lineBreakMode = .byWordWrapping
tf.cell?.wraps = true
tf.translatesAutoresizingMaskIntoConstraints = false
return tf
}()
content.addSubview(headerLabel)
// Prepare scroll view + text view
scrollView.borderType = .noBorder
scrollView.drawsBackground = true
scrollView.backgroundColor = .textBackgroundColor
scrollView.documentView = outputTextView
// Make the document view track the scroll view's content size
outputTextView.frame = scrollView.contentView.bounds
outputTextView.autoresizingMask = [.width, .height]
content.addSubview(scrollView)
// Upper-right interval selector
content.addSubview(intervalPopup)
// Bottom-left control bar (minimize only; refresh is always automatic)
let controls = NSStackView()
controls.orientation = .horizontal
controls.spacing = 8
controls.alignment = .centerY
controls.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(controls)
let minimizeButton = NSButton(title: "_", target: nil, action: nil)
minimizeButton.bezelStyle = .texturedRounded
minimizeButton.toolTip = "Minimize to Status Bar"
minimizeButton.target = self
minimizeButton.action = #selector(didTapMinimize)
controls.addArrangedSubview(minimizeButton)
// NSWindow.contentLayoutGuide is typed as Any? on AppKit; cast safely to NSLayoutGuide.
// Fallback to the content view's anchors if unavailable.
let layoutGuide = window.contentLayoutGuide as Any?
let guide = (layoutGuide as? NSLayoutGuide)
let topAnchorRef = guide?.topAnchor ?? content.topAnchor
let leadingAnchorRef = guide?.leadingAnchor ?? content.leadingAnchor
let trailingAnchorRef = guide?.trailingAnchor ?? content.trailingAnchor
let bottomAnchorRef = guide?.bottomAnchor ?? content.bottomAnchor
NSLayoutConstraint.activate([
// Header at the top inside the content layout guide (avoids title bar)
headerLabel.topAnchor.constraint(equalTo: topAnchorRef, constant: 12),
headerLabel.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 12),
headerLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchorRef, constant: -12),
// Interval selector at top-right, aligned with header baseline
intervalPopup.centerYAnchor.constraint(equalTo: headerLabel.centerYAnchor),
intervalPopup.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -12),
// Scroll view occupies the content area below header
scrollView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
scrollView.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
scrollView.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -44),
// Controls pinned bottom-left inside safe area
controls.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
controls.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -8)
])
// Wire actions
intervalPopup.target = self
intervalPopup.action = #selector(didChangeInterval)
// Initial load and timer
runAndDisplay()
resetTimer()
// Perform an initial fit shortly after layout
DispatchQueue.main.async { [weak self] in self?.adjustFontToFit() }
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show() {
guard let window = window else { return }
MainWindowController.positionWindowAtLeftEdge(window)
window.makeKeyAndOrderFront(nil)
// Ensure the text view becomes first responder so Cmd+C routes to it
window.makeFirstResponder(outputTextView)
NSApp.activate(ignoringOtherApps: false)
}
func hide() {
window?.orderOut(nil)
}
var isVisible: Bool { window?.isVisible ?? false }
// MARK: - Positioning helpers
static func positionWindowAtLeftEdge(_ window: NSWindow, margin: CGFloat = 12) {
guard let screen = window.screen ?? NSScreen.main else { return }
let vis = screen.visibleFrame
var frame = window.frame
// Place near left edge and below the menu bar, keep current size
frame.origin.x = vis.origin.x + margin
// Align top to visible frame top with small margin
frame.origin.y = vis.maxY - frame.height - margin
// Ensure not offscreen vertically
if frame.origin.y < vis.origin.y + margin { frame.origin.y = vis.origin.y + margin }
window.setFrame(frame, display: true, animate: false)
}
// MARK: - Actions
@objc private func didTapMinimize() { actions.onMinimizeToStatusBar() }
// MARK: - Script output rendering and auto-refresh
@objc private func didChangeInterval() { resetTimer() }
private func selectedIntervalSeconds() -> TimeInterval {
switch intervalPopup.titleOfSelectedItem {
case "10s": return 10
case "30s": return 30
case "1 min": return 60
case "5 min": return 300
default: return 30
}
}
private func resetTimer() {
refreshTimer?.invalidate()
let interval = selectedIntervalSeconds()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
self?.runAndDisplay()
}
RunLoop.main.add(refreshTimer!, forMode: .common)
}
private func runAndDisplay() {
// Show a quick placeholder immediately so the view is never blank
DispatchQueue.main.async { [weak self] in
self?.outputTextView.string = "Loading status…"
self?.outputTextView.scrollToBeginningOfDocument(nil)
}
DispatchQueue.global(qos: .utility).async {
let res = PFScriptBridge.status()
let now = ISO8601DateFormatter().string(from: Date())
var text = ""
// Always display combined stdout + stderr; include exit code if non-zero
text = res.out + (res.err.isEmpty ? "" : (res.out.isEmpty ? res.err : "\n" + res.err))
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
text = "(no output received)"
}
if res.code != 0 { text = "[exit code: \(res.code)]\n" + text }
let invocation = PFScriptBridge.invocationDescription()
let header = "# \(invocation) — status (\(now))\n\n"
let final = header + text
DispatchQueue.main.async { [weak self] in
self?.outputTextView.string = final
// Ensure we show the beginning of the output (header)
self?.outputTextView.scrollToBeginningOfDocument(nil)
self?.outputTextView.scrollRangeToVisible(NSRange(location: 0, length: 0))
self?.adjustFontToFit()
}
}
}
}
// MARK: - Auto-fit font to keep full output visible
extension MainWindowController: NSWindowDelegate {
func windowDidResize(_ notification: Notification) {
adjustFontToFit()
}
}
private extension MainWindowController {
func adjustFontToFit() {
guard let font = outputTextView.font,
let tc = outputTextView.textContainer,
let lm = outputTextView.layoutManager else { return }
// Available height inside the scroll content
let availableHeight = scrollView.contentView.bounds.height
guard availableHeight > 0 else { return }
// Helper to measure content height for a given font size
func contentHeight(for size: CGFloat) -> CGFloat {
outputTextView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
// Invalidate layout and measure
lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: lm.numberOfGlyphs), actualCharacterRange: nil)
lm.ensureLayout(for: tc)
let used = lm.usedRect(for: tc)
return used.height + outputTextView.textContainerInset.height * 2.0
}
// Bounds for font size
var minSize: CGFloat = 9
var maxSize: CGFloat = max(12, font.pointSize)
// If there is lots of space, allow growing up to a sensible cap
let cap: CGFloat = 16
maxSize = min(maxSize, cap)
// First, try to shrink if needed
var best = maxSize
var h = contentHeight(for: best)
if h > availableHeight {
// Decrease until it fits or we hit min
var s = best
while s > minSize {
let next = max(minSize, s - 0.5)
let nh = contentHeight(for: next)
if nh <= availableHeight { best = next; h = nh; break }
s = next
}
} else {
// Try to grow a bit (keeping content fully visible) for readability
var s = best
while s < cap {
let next = min(cap, s + 0.5)
let nh = contentHeight(for: next)
if nh > availableHeight { break }
best = next; h = nh; s = next
}
}
// Apply the chosen size (already applied during measurement but ensure final value)
outputTextView.font = NSFont.monospacedSystemFont(ofSize: best, weight: .regular)
// Keep view scrolled to top so header stays visible after relayout
outputTextView.scrollToBeginningOfDocument(nil)
}
}