diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift new file mode 100644 index 0000000..24786f7 --- /dev/null +++ b/Sources/phbar/AppDelegate.swift @@ -0,0 +1,23 @@ +import AppKit + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + var barController: PHController! + var window: BarWindow! + + init(screen: NSScreen, blocks: [PHBlock]) { + super.init() + + self.barController = PHController(screen: screen, blocks: blocks) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + window = BarWindow(controller: barController) + window.orderFront(nil) + barController.startAutoRefresh() + } + + func applicationWillTerminate(_ notification: Notification) { + barController.stopAutoRefresh() + } +} diff --git a/Sources/phbar/CLI/cli+start.swift b/Sources/phbar/CLI/cli+start.swift new file mode 100644 index 0000000..aa5848c --- /dev/null +++ b/Sources/phbar/CLI/cli+start.swift @@ -0,0 +1,55 @@ +import AppKit +import ArgumentParser +import Foundation + +extension phbar { + struct start: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start the status bar." + ) + + mutating func run() throws { + let screen = try targetScreen(monitor: 0) + + // NOTE: Make sure NSApp.run() runs in the main thread + dispatchPrecondition(condition: .onQueue(.main)) + + try MainActor.assumeIsolated { + let blocks = try PHBlock.load() + + let delegate = AppDelegate(screen: screen, blocks: blocks) + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + app.delegate = delegate + app.run() + + throw ExitCode.success + } + } + } +} + +// Monitor + +extension phbar.start { + private func targetScreen(monitor: Int) throws -> NSScreen { + let screens = NSScreen.screens + guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") } + + if monitor >= 0, monitor < screens.count { + return screens[monitor] + } + + // Default: screen containing the mouse cursor + let mouseLocation = NSEvent.mouseLocation + guard + let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) + ?? screens.first + else { + throw CleanExit.message("No monitor founded") + } + + return screen + } +} diff --git a/Sources/phbar/Controllers/PHController.swift b/Sources/phbar/Controllers/PHController.swift new file mode 100644 index 0000000..1124e4b --- /dev/null +++ b/Sources/phbar/Controllers/PHController.swift @@ -0,0 +1,36 @@ +import SwiftUI + +@MainActor +final class PHController: ObservableObject { + let screen: NSScreen + @Published var blocks: [PHBlock] + + init(screen: NSScreen, blocks: [PHBlock]) { + self.screen = screen + self.blocks = blocks + } + + // MARK: - Refresh + + /// Start auto-refresh for every block. + func startAutoRefresh() { + for block in blocks { + block.startAutoRefresh() + } + } + + /// Stop auto-refresh for every block. + func stopAutoRefresh() { + for block in blocks { + block.stopAutoRefresh() + } + } + + /// Force every block to recompute its label immediately (manual refresh). + /// Each block updates concurrently; the view refreshes as results arrive. + func refresh() { + for block in blocks { + Task { await block.update() } + } + } +} diff --git a/Sources/phbar/Models/PHBlock.swift b/Sources/phbar/Models/PHBlock.swift new file mode 100644 index 0000000..7105468 --- /dev/null +++ b/Sources/phbar/Models/PHBlock.swift @@ -0,0 +1,148 @@ +import ArgumentParser +import Foundation +import SwiftUI +import TOML + +@MainActor +final class PHBlock: ObservableObject, Decodable, Identifiable { + let id = UUID() + let command: String + let name: String? + let style: String? + let refresh: Double? + + @Published private(set) var label = "" + + /// The repeating refresh task, if any. + private var refreshTask: Task? + + private enum CodingKeys: String, CodingKey { + case command, name, style, refresh + } + + deinit { + refreshTask?.cancel() + } + + // MARK: - Refresh + + /// Start keeping the label up to date. + /// + /// The label is always recomputed immediately. When `refresh` is set to a + /// positive number of seconds, it is then recomputed on that interval until + /// `stopAutoRefresh()` is called. With no interval set, only the initial + /// computation runs and later updates must be triggered manually via + /// `update()`. + func startAutoRefresh() { + stopAutoRefresh() + + guard let interval = refresh, interval > 0 else { + // No interval: compute once, rely on manual updates afterwards. + Task { await update() } + return + } + + let milliseconds = Int(interval * 1000) + refreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.update() + try? await Task.sleep(for: .milliseconds(milliseconds)) + } + } + } + + /// Cancel the repeating auto-refresh, if any. + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil + } + + /// Recompute the label from `command`. Used by auto-refresh and manual updates. + func update() async { + label = await compute() + } + + /// Runs the provided command and returns its stdout. + /// + /// Runs off the main actor so it can be awaited safely from SwiftUI views + /// without blocking the UI. + /// + /// - Returns: A non-optional String to use as the block label. + func compute() async -> String { + let command = command + return await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", command] + + // Inherit the parent's environment (includes $PATH, etc.) + process.environment = ProcessInfo.processInfo.environment + + // Capture stdout via a pipe (otherwise standardOutput is always nil). + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } catch { + // Silently ignore failures + return "" + } + }.value + } +} + +// Loading + +extension PHBlock { + private struct Wrapper: Decodable { + let blocks: [PHBlock] + + enum CodingKeys: String, CodingKey { + case blocks = "block" + } + } + + /// Load blocks from the default path (~/.config/phbar/blocks.toml). + /// If the file doesn't exist or fails to parse, defaults are used (non-fatal). + static func load() throws -> [PHBlock] { + let url = FileManager.default.homeDirectoryForCurrentUser.appending( + path: ".config/phbar/blocks.toml" + ) + + if FileManager.default.fileExists(atPath: url.relativePath) { + return try load(from: url) + } else { + throw phbar.Error("Failed to load blocks file") + } + } + + /// Load theme from a file at URL. + /// If the file doesn't exist or fails to parse, the execution is interrupted. + static func load(from url: URL) throws -> [PHBlock] { + do { + let data = try Data(contentsOf: url) + guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { + throw phbar.Error("the file is empty") + } + return try load(from: contents) + } catch { + throw phbar.Error("Failed to load blocks file", underlyingError: error) + } + } + + /// Load theme from a TOML string. + /// If the content fails to parse, the execution is interrupted. + static func load(from contents: String) throws -> [PHBlock] { + do { + let decoder = TOMLDecoder() + let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents) + return wrapper.blocks + } catch { + throw phbar.Error("Failed to parse blocks file", underlyingError: error) + } + } +} diff --git a/Sources/phbar/Views/BarView.swift b/Sources/phbar/Views/BarView.swift new file mode 100644 index 0000000..8aa4aa6 --- /dev/null +++ b/Sources/phbar/Views/BarView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct BarView: View { + @StateObject private var bar: PHController + + init(controller: PHController) { + self._bar = StateObject(wrappedValue: controller) + } + + var body: some View { + HStack(alignment: .center, spacing: 0) { + ForEach(bar.blocks) { block in + TextBlock(block: block) + } + + SpaceBlock() + } + .font(.custom("Comic Code", size: 14)) + .frame(height: 30) + .ignoresSafeArea() + .environmentObject(bar) + } +} diff --git a/Sources/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift new file mode 100644 index 0000000..71a3108 --- /dev/null +++ b/Sources/phbar/Views/BarWindow.swift @@ -0,0 +1,54 @@ +import AppKit +import SwiftUI + +final class BarWindow: NSPanel { + let barController: PHController + var barView: NSHostingView! + + init(controller: PHController) { + self.barController = controller + + super.init( + contentRect: Self.computeFrame(from: barController), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + // self.delegate = self + self.title = "PMenu" + self.isFloatingPanel = true + self.level = .floating + self.animationBehavior = .utilityWindow + self.isOpaque = false + self.backgroundColor = .clear + // self.hasShadow = pmenu.config.window.shadow + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + self.acceptsMouseMovedEvents = false + self.isMovable = false + self.ignoresMouseEvents = false + self.hidesOnDeactivate = false + self.titleVisibility = .hidden + self.titlebarAppearsTransparent = true + self.isRestorable = false + self.displaysWhenScreenProfileChanges = true + + self.barView = NSHostingView(rootView: BarView(controller: barController)) + barView.autoresizingMask = [.width, .height] + barView.frame = self.contentView?.bounds ?? .zero + + self.contentView = barView + } +} + +extension BarWindow { + /// Calculate window frame and position. + static func computeFrame(from controller: PHController) -> NSRect { + let screenFrame = controller.screen.frame + let height: CGFloat = 30 + let x = screenFrame.minX + let y = screenFrame.maxY - height + + return NSRect(x: x, y: y, width: screenFrame.width, height: height) + } +} diff --git a/Sources/phbar/Views/Blocks/SpaceBlock.swift b/Sources/phbar/Views/Blocks/SpaceBlock.swift new file mode 100644 index 0000000..08f656d --- /dev/null +++ b/Sources/phbar/Views/Blocks/SpaceBlock.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct SpaceBlock: View { + var body: some View { + Rectangle() + .fill(.blue) + } +} diff --git a/Sources/phbar/Views/Blocks/TextBlock.swift b/Sources/phbar/Views/Blocks/TextBlock.swift new file mode 100644 index 0000000..639303b --- /dev/null +++ b/Sources/phbar/Views/Blocks/TextBlock.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct TextBlock: View { + @ObservedObject var block: PHBlock + + var body: some View { + ZStack { + Rectangle() + .fill(.red) + + Text(block.label) + .foregroundColor(.white) + } + .fixedSize(horizontal: true, vertical: false) + } +} diff --git a/Sources/phbar/cli.swift b/Sources/phbar/cli.swift new file mode 100644 index 0000000..bca59d1 --- /dev/null +++ b/Sources/phbar/cli.swift @@ -0,0 +1,62 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book +// +// Swift Argument Parser +// https://swiftpackageindex.com/apple/swift-argument-parser/documentation + +import ArgumentParser +import Foundation + +@main +struct phbar: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "phbar", + abstract: "Modular status bar for macOS.", + discussion: """ + pmenu reads a list of newline-separated items from stdin and presents them to the user. + When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates. + Entering text will narrow the items to those matching the tokens in the input. + """, + version: "1.0.0", + subcommands: [start.self] + ) +} + +extension phbar { + struct Error: LocalizedError { + let message: String + let underlyingError: Swift.Error? + + init(_ message: String, underlyingError: Swift.Error? = nil) { + self.message = message + self.underlyingError = underlyingError + } + + var errorDescription: String? { + guard let underlyingError else { return message } + + if let decodingError = underlyingError as? DecodingError { + return "\(message): \(Self.describe(decodingError))" + } + return "\(message): \(underlyingError.localizedDescription)" + } + + private static func describe(_ error: DecodingError) -> String { + switch error { + case .keyNotFound(let key, let context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return "missing key '\(key.stringValue)' at \(path.isEmpty ? "root" : path)" + case .typeMismatch(let type, let context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return "type mismatch for \(type) at \(path)" + case .valueNotFound(let type, let context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return "missing value of type \(type) at \(path)" + case .dataCorrupted(let context): + return "corrupted data → \(context.debugDescription)" + @unknown default: + return error.localizedDescription + } + } + } +} diff --git a/Sources/phbar/phbar.swift b/Sources/phbar/phbar.swift deleted file mode 100644 index e7ed9be..0000000 --- a/Sources/phbar/phbar.swift +++ /dev/null @@ -1,14 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book -// -// Swift Argument Parser -// https://swiftpackageindex.com/apple/swift-argument-parser/documentation - -import ArgumentParser - -@main -struct phbar: ParsableCommand { - mutating func run() throws { - print("Hello, world!") - } -}