basic block structure and auto-refresh

This commit is contained in:
2026-07-10 17:19:04 +02:00
parent a96efee4cf
commit 7d281d1039
10 changed files with 425 additions and 14 deletions
+23
View File
@@ -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()
}
}
+55
View File
@@ -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
}
}
@@ -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() }
}
}
}
+148
View File
@@ -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<Void, Never>?
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)
}
}
}
+23
View File
@@ -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)
}
}
+54
View File
@@ -0,0 +1,54 @@
import AppKit
import SwiftUI
final class BarWindow: NSPanel {
let barController: PHController
var barView: NSHostingView<BarView>!
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)
}
}
@@ -0,0 +1,8 @@
import SwiftUI
struct SpaceBlock: View {
var body: some View {
Rectangle()
.fill(.blue)
}
}
@@ -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)
}
}
+62
View File
@@ -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
}
}
}
}
-14
View File
@@ -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!")
}
}