Compare commits

...

19 Commits

50 changed files with 919 additions and 703 deletions
+38 -13
View File
@@ -1,47 +1,69 @@
# Makefile for phbar # Makefile for phbar
# #
# Usage: make <target> # Usage: make <target>
#
# NOTE: Never invoke this Makefile with `sudo` (e.g. `sudo make install`).
# Running `swift build` as root corrupts `.build/` with root-owned files that
# break subsequent non-root builds (you get "invalid access to …/DerivedSources").
# The `install`/`uninstall` targets elevate *only* the file copy, and only when
# the destination is not user-writable, so `sudo` is never needed from the user.
BIN_DIR := $(HOME)/.local/bin # Install directory. Override with: make install BIN_DIR=/opt/homebrew/bin
BIN_DIR ?= /usr/local/bin
BUILD_DIR := .build/release BUILD_DIR := .build/release
BINARIES := phbar BINARIES := phbar
.PHONY: all build install clean test run # Guard: any target that runs Swift must not run under sudo, otherwise it would
# sprinkle root-owned files across .build/. It fails fast with a helpful message
# instead of corrupting the build directory. Recursively-expanded (`=`) so that
# the `$@` automatic variable resolves per-target at recipe time.
guard_not_root = @if [ "$$(id -u)" = "0" ] && [ -n "$$SUDO_USER" ]; then \
echo "❌ 'make $@' must not run under sudo (it would corrupt .build/)." >&2; \
echo " Run 'make install' without sudo; it elevates only the copy step." >&2; \
exit 1; \
fi
.PHONY: all build install clean test run uninstall reinstall help
all: build all: build
# Build all targets in release mode # Build all targets in release mode
build: build:
$(guard_not_root)
swift build -c release swift build -c release
# Build and install binaries to ~/.local/bin # Build and install binaries. Elevates only the copy when BIN_DIR isn't writable.
install: build install: build
@mkdir -p $(BIN_DIR) @mkdir -p $(BIN_DIR)
@for bin in $(BINARIES); do \ @if [ -w "$(BIN_DIR)" ]; then CP="cp -f"; else CP="sudo cp -f"; fi; \
cp -f $(BUILD_DIR)/$$bin $(BIN_DIR)/; \ for bin in $(BINARIES); do \
done $$CP $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
done
@echo "✅ Installed $(BINARIES) to $(BIN_DIR)" @echo "✅ Installed $(BINARIES) to $(BIN_DIR)"
# Clean build artifacts # Clean build artifacts
clean: clean:
$(guard_not_root)
swift package clean swift package clean
# Run tests # Run tests
test: test:
$(guard_not_root)
swift test swift test
# Quick test: build, install, and verify # Quick test: build, install, and verify
run: install run: install
@echo "Verifying installation..." @echo "Verifying installation..."
@for bin in $(BINARIES); do \ @for bin in $(BINARIES); do \
which $$bin > /dev/null && echo "$$bin: $(shell which $$bin)" || echo "$$bin: not found"; \ which $$bin > /dev/null && echo "$$bin: $$($$bin --version 2>/dev/null | head -1)" || echo "$$bin: not found"; \
done done
# Uninstall binaries from ~/.local/bin # Uninstall binaries. Elevates only the remove when BIN_DIR isn't writable.
uninstall: uninstall:
@for bin in $(BINARIES); do \ @if [ -w "$(BIN_DIR)" ]; then RM="rm -f"; else RM="sudo rm -f"; fi; \
rm -f $(BIN_DIR)/$$bin; \ for bin in $(BINARIES); do \
done $$RM $(BIN_DIR)/$$bin; \
done
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)" @echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
# Rebuild and reinstall (clean + install) # Rebuild and reinstall (clean + install)
@@ -51,9 +73,12 @@ reinstall: clean install
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " make build - Build in release mode" @echo " make build - Build in release mode"
@echo " make install - Build and install to ~/.local/bin" @echo " make install - Build and install to $(BIN_DIR)"
@echo " make clean - Clean build artifacts" @echo " make clean - Clean build artifacts"
@echo " make test - Run tests" @echo " make test - Run tests"
@echo " make run - Build, install, and verify" @echo " make run - Build, install, and verify"
@echo " make uninstall - Remove binaries from ~/.local/bin" @echo " make uninstall - Remove binaries from $(BIN_DIR)"
@echo " make reinstall - Clean and reinstall" @echo " make reinstall - Clean and reinstall"
@echo ""
@echo "Override the install location with BIN_DIR, e.g.:"
@echo " make install BIN_DIR=$(HOME)/.local/bin"
+1 -4
View File
@@ -34,10 +34,7 @@ let package = Package(
exclude: [ exclude: [
"Models/PHEvent/README.md" "Models/PHEvent/README.md"
], ],
resources: [ resources: [.copy("config")]
.process("Models/PHConfig/config.toml"),
.process("Models/PHTheme/theme.toml"),
]
), ),
.testTarget( .testTarget(
name: "phbarTests", name: "phbarTests",
-72
View File
@@ -1,72 +0,0 @@
import AppKit
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var controllers: [BarController] = []
var windows: [BarWindow] = []
private var observers: [IPCObserver] = []
/// Create one bar controller per screen. Each screen resolves its own
/// theme, window and block set from the config, so refresh state and layout stay
/// independent across monitors.
init(
config: PHConfig,
screens: [NSScreen],
debug: Bool
) throws {
super.init()
// The merged environment (process + `[env]`) is shared by every screen:
// scripts see it as their process environment, and themes expand `$VAR`
// references in colors/fonts against it.
let environment = PHEnvironment.process.merging(config.env)
for screen in screens {
let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen)
let theme = try PHTheme.load(config.theme(screenName: name, screenIndex: index), environment: environment)
let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index))
controllers.append(
BarController(
config: config,
screen: screen,
theme: theme,
blocks: blocks,
environment: environment,
debug: debug
)
)
}
}
func applicationDidFinishLaunching(_ notification: Notification) {
for controller in controllers {
let window = BarWindow(controller: controller)
window.orderFront(nil)
windows.append(window)
controller.startAutoRefresh()
}
// Listen for refresh notifications from `phbar refresh`.
let token = IPC.observe(.refresh) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
observers.append(token)
}
func refresh() {
for controller in controllers {
controller.refresh()
}
}
func applicationWillTerminate(_ notification: Notification) {
for controller in controllers {
controller.stopAutoRefresh()
}
observers.removeAll()
}
}
-41
View File
@@ -1,41 +0,0 @@
import AppKit
import ArgumentParser
import Foundation
extension phbar {
struct start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar."
)
@Flag(name: .long, help: "Display visual guides to help align elements.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
// Spawn a bar on every screen; per-monitor window and block-set
// selection is driven by the config's `[monitor]` overrides.
let screens = NSScreen.screens
guard !screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = try AppDelegate(
config: config,
screens: screens,
debug: debug
)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
@@ -0,0 +1,69 @@
import ArgumentParser
import Foundation
extension PHBar {
struct Install: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
private var source: URL? {
Bundle.module.url(forResource: "config", withExtension: "")
}
private var destination: URL {
let environment = ProcessInfo.processInfo.environment
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
let standardConfigDirectory = homeDirectory.appending(path: ".config")
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
in: .whitespaces)
var candidateDirectory: URL
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
candidateDirectory = standardConfigDirectory
} else {
candidateDirectory = homeDirectory
}
return candidateDirectory.appending(path: "phbar")
}
mutating func run() throws {
do {
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
try FileManager.default.copyItem(at: source, to: destination)
try makeExecutable()
} catch {
throw PHBar.Error("Failed to create config directory", underlyingError: error)
}
throw CleanExit.message("Config directory created at \(destination.relativePath)")
}
private func makeExecutable() throws {
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
let scripts = try FileManager.default.contentsOfDirectory(
atPath: scriptsDirectory.relativePath
)
for script in scripts {
let path = scriptsDirectory.appending(path: script)
var attributes: [FileAttributeKey: Any] = [:]
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
}
}
}
}
@@ -1,7 +1,7 @@
import ArgumentParser import ArgumentParser
extension phbar { extension PHBar {
struct refresh: ParsableCommand { struct Refresh: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "refresh", commandName: "refresh",
abstract: "Refresh the running status bar." abstract: "Refresh the running status bar."
+44
View File
@@ -0,0 +1,44 @@
import AppKit
import ArgumentParser
import Foundation
extension PHBar {
struct Start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
@Flag(name: .long, help: "Display visual guides and print verbose output.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
let theme = try PHTheme.load(config.theme)
let layouts = try PHLayout.load(["default", "mini"])
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
@@ -0,0 +1,21 @@
extension String {
/// Expand `$VAR` and `${VAR}` references against `variables`.
///
/// Unresolved references are left intact rather than blanked, so a typo
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
/// vanishing.
func expanded(from environment: [String: String]) -> String {
var result = self
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
environment[String(match.1)] ?? String(match.0)
}
// `$VAR`
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
environment[String(match.1)] ?? String(match.0)
}
return result
}
}
+69
View File
@@ -0,0 +1,69 @@
import AppKit
@MainActor
final class PHBarDelegate: NSObject, NSApplicationDelegate {
/// One window per attached screen the single source of truth. The matching
/// controllers are reached through `windows[i].barController`.
var windows: [BarWindow] = []
/// Convenience accessor over `windows`.
var controllers: [BarController] { windows.map(\.barController) }
/// Resolves a controller for any screen from the current config.
var factory: PHBarFactory
private var observers: [IPCObserver] = []
private var screenObserver: NSObjectProtocol?
init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) {
self.factory = PHBarFactory(
config: config,
theme: theme,
layouts: layouts,
debug: debug
)
super.init()
}
func applicationDidFinishLaunching(_ notification: Notification) {
syncScreens()
// React to monitor connect/disconnect and resolution changes. macOS
// delivers a single app-level notification for any of these; `syncScreens`
// diffs so unchanged bars are left untouched.
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.syncScreens() }
}
// Listen for refresh notifications from `phbar refresh`.
let token = IPC.observe(.refresh) { [weak self] _ in
Task { @MainActor in self?.refresh() }
}
observers.append(token)
}
func refresh() {
for controller in controllers {
controller.refresh()
}
}
func applicationWillTerminate(_ notification: Notification) {
for controller in controllers {
controller.stopAutoRefresh()
}
observers.removeAll()
if let screenObserver {
NotificationCenter.default.removeObserver(screenObserver)
self.screenObserver = nil
}
}
func stderr(_ message: String) {
FileHandle.standardError.write(Data("phbar: \(message)\n".utf8))
}
}
@@ -0,0 +1,42 @@
import AppKit
extension PHBarDelegate {
/// Build, show, and start a bar for `screen`. Returns `false` (and prints to
/// stderr) if the screen's theme or blocks fail to load, so one bad screen
/// can't take down the others.
@discardableResult
func addBar(for screen: NSScreen) -> Bool {
let controller: BarController
do {
controller = try factory.make(for: screen)
} catch {
stderr("skipping screen \(screen.localizedName): \(error.localizedDescription)")
return false
}
let window = BarWindow(controller: controller)
window.orderFront(nil)
windows.append(window)
controller.startAutoRefresh()
return true
}
/// Stop refresh, hide, and drop the bar running on `screen` (if any).
func removeBar(for screen: NSScreen) {
guard let index = windows.firstIndex(where: { $0.barController.screen === screen }) else {
return
}
windows[index].barController.stopAutoRefresh()
windows[index].orderOut(nil)
windows.remove(at: index)
}
/// The window currently running on `screen`, if any.
func window(for screen: NSScreen) -> BarWindow? {
windows.first { $0.barController.screen === screen }
}
/// The window currently running on the monitor named `name`, if any.
func window(named name: String) -> BarWindow? {
windows.first { $0.barController.screen.localizedName == name }
}
}
@@ -0,0 +1,28 @@
import AppKit
extension PHBarDelegate {
/// Reconcile live bars against `screens`: tear down bars whose screen is
/// gone, recompute frames for screens whose geometry may have changed, and
/// add bars for newly attached screens.
///
/// A screen whose theme or blocks fail to load is skipped (with a stderr
/// message) rather than aborting the rest.
///
/// - Parameter screens: Defaults to `NSScreen.screens`; injected for tests.
func syncScreens(screens: [NSScreen] = NSScreen.screens) {
let plan = PHScreenSync.diff(
current: screens,
occupied: controllers.map(\.screen)
)
for screen in plan.removed {
removeBar(for: screen)
}
for screen in plan.kept {
window(for: screen)?.recomputeFrame()
}
for screen in plan.added {
addBar(for: screen)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import AppKit
/// Builds a `BarController` for a screen by resolving its layout
/// and window from the config.
@MainActor
struct PHBarFactory {
let config: PHConfig
let theme: PHTheme
let layouts: [String: PHLayout]
let debug: Bool
/// Resolve layout for `screen` and build its controller.
///
/// - Parameter screen: The screen to build a bar for. Its localized name
/// and index in `NSScreen.screens` select any per-monitor config override.
/// - Throws: `PHBlock.load` error for the resolved names.
func make(for screen: NSScreen) throws -> BarController {
let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen)
guard let layout = layouts[config.layout(screenName: name, screenIndex: index)] else {
throw PHBar.Error("Layout `\(name)` not found.")
}
return BarController(
config: config,
screen: screen,
theme: theme,
layout: layout,
debug: debug
)
}
}
+3 -9
View File
@@ -5,7 +5,6 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID() let id = UUID()
let name: String let name: String
let _command: String?
let textName: String? let textName: String?
@Published var text: PHThemeText = .default @Published var text: PHThemeText = .default
let styleName: String? let styleName: String?
@@ -14,8 +13,8 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let centered: Bool? let centered: Bool?
var debug: Bool = false var debug: Bool = false
var command: String { nonisolated var command: String {
_command ?? PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
} }
var visible: Bool { var visible: Bool {
@@ -26,10 +25,6 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
/// `nil` when omitted from config. /// `nil` when omitted from config.
let events: [PHEvent]? let events: [PHEvent]?
/// Variables made visible to block scripts. Defaults to phbar's process
/// environment; `BarController` overlays the `[env]` section on top.
var environment: PHEnvironment = .process
/// Event source registry used to subscribe to system events. Defaults to the /// Event source registry used to subscribe to system events. Defaults to the
/// shared instance; inject a custom one for testing. /// shared instance; inject a custom one for testing.
var registry: PHEventRegistry = .shared var registry: PHEventRegistry = .shared
@@ -44,11 +39,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
/// Guards against stacking concurrent updates during rapid event bursts /// Guards against stacking concurrent updates during rapid event bursts
/// (e.g. dragging the volume slider fires many events in quick succession). /// (e.g. dragging the volume slider fires many events in quick succession).
var updateScheduled = false var resolveScheduled = false
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
case _command = "command"
case textName = "text" case textName = "text"
case styleName = "style" case styleName = "style"
case refresh, events, centered case refresh, events, centered
@@ -0,0 +1,11 @@
extension PHBlock {
func resolve(with event: PHGestureEvent) {
var environment = [String: String]()
environment.updateValue(event.type.rawValue, forKey: PHGestureEvent.typeKey)
if let data = event.data {
environment.updateValue(data, forKey: PHGestureEvent.infoKey)
}
resolve(merging: environment)
}
}
@@ -1,52 +0,0 @@
import Foundation
import TOML
extension PHBlock {
private struct Wrapper: Decodable {
let blocks: [PHBlock]
private enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
/// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
///
/// - Parameter configDirectory: Override the lookup root (used by tests);
/// defaults to the resolved config directory (see `PHPaths`).
static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] {
let base = configDirectory ?? PHPaths.configDirectory
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
throw phbar.Error("Failed to load blocks '\(blocks)'")
}
return try load(from: setFile)
}
/// 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)
}
}
}
@@ -19,7 +19,7 @@ extension PHBlock {
stopAutoRefresh() stopAutoRefresh()
// Immediate refresh so the bar isn't blank until the first trigger fires. // Immediate refresh so the bar isn't blank until the first trigger fires.
scheduleUpdate() resolve()
// 1. Periodic refresh. // 1. Periodic refresh.
if let interval = refresh, interval > 0 { if let interval = refresh, interval > 0 {
@@ -28,7 +28,7 @@ extension PHBlock {
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds)) try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break } if Task.isCancelled { break }
await self?.update() await self?.resolve()
} }
} }
} }
@@ -37,7 +37,7 @@ extension PHBlock {
// system listener only while at least one block subscribes to it. // system listener only while at least one block subscribes to it.
for event in (events ?? []) { for event in (events ?? []) {
let subscription = registry.subscribe(event) { [weak self] in let subscription = registry.subscribe(event) { [weak self] in
self?.scheduleUpdate() self?.resolve()
} }
subscriptions.append(subscription) subscriptions.append(subscription)
} }
@@ -50,80 +50,4 @@ extension PHBlock {
subscriptions.forEach { $0.cancel() } subscriptions.forEach { $0.cancel() }
subscriptions.removeAll() subscriptions.removeAll()
} }
/// Recompute the label from `command`. Used by the interval loop, event
/// handlers, and manual updates.
func update() async {
label = await compute()
}
/// Request an update on the main actor, coalescing rapid bursts into a single
/// recomputation.
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
Task { [weak self] in
defer { self?.updateScheduled = false }
await self?.update()
}
}
func handleGesture(_ event: PHGestureEvent) {
Task { label = await compute(with: event) }
}
/// 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(with gestureEvent: PHGestureEvent? = nil) async -> String? {
guard kind == .text else { return nil }
let command = command
let environment = self.environment
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process()
// Commands run with the resolved config root (see `PHPaths`) as their
// working directory, regardless of which block set they belong to, so
// relative paths in user scripts stay stable.
process.currentDirectoryURL = PHPaths.configDirectory
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
// Scripts inherit phbar's process environment with the `[env]` section
// overlaid, so user-defined variables and PATH extensions are visible.
process.environment = environment.variables
if let gestureEvent {
let type = gestureEvent.type.rawValue
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
if let data = gestureEvent.data {
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
}
}
// 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()
let string = String(data: data, encoding: .utf8)
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
return lines
} catch {
// Silently ignore failures
return nil
}
}.value
guard let lines else { return nil }
if self.debug { for line in lines { print(line) } }
return lines.last?.description
}
} }
@@ -0,0 +1,118 @@
import Foundation
extension PHBlock {
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) {
guard canResolve() else { return }
guard !resolveScheduled else { return }
resolveScheduled = true
Task(name: command, priority: .userInitiated) { [weak self] in
defer { self?.resolveScheduled = false }
do {
self?.label = try await self?.resolve(merging: environment)
} catch {
self?.printStandardError(error.localizedDescription)
}
}
}
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) async {
guard canResolve() else { return }
do {
label = try await resolve(merging: environment)
} catch {
printStandardError(error.localizedDescription)
}
}
private func resolve(merging environment: [String: String]?) async throws -> String? {
let customEnvironment = environment
var environment = ProcessInfo.processInfo.environment
if let customEnvironment {
environment.merge(customEnvironment) { (_, new) in new }
}
return try await resolve(with: environment)
}
private func resolve(with environment: [String: String]) async throws -> String? {
do {
let (stdout, stderr) = try await resolve(command, with: environment)
printStandardError(stderr)
printStandardOutput(stdout)
guard let lines = stdout?.split(whereSeparator: \.isNewline),
let last = lines.last
else {
return nil
}
return String(last)
} catch {
throw error
}
}
private func canResolve() -> Bool {
guard kind == .text else { return false }
return true
}
private func resolve(_ command: String, with environment: [String: String]) async throws -> (
stdout: String?,
stderr: String?
) {
return try await withCheckedThrowingContinuation { continuation in
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.currentDirectoryURL = PHPaths.configDirectory
process.environment = environment
process.arguments = ["-c", command]
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: stdoutData, encoding: .utf8)
let stderr = String(data: stderrData, encoding: .utf8)
continuation.resume(returning: (stdout, stderr))
} catch {
continuation.resume(throwing: error)
}
}
}
/// Print a message to standard output.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardOutput(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardOutput.write(data)
}
/// Print a message to standard error.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardError(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardError.write(data)
}
}
@@ -1,10 +1,11 @@
enum PHBlockKind: String { enum PHBlockKind: String {
case text, space case text, space, divider
} }
extension PHBlock { extension PHBlock {
var kind: PHBlockKind { var kind: PHBlockKind {
if name.starts(with: "_space") { return .space } if name.starts(with: "_space") { return .space }
if name.starts(with: "_divider") { return .divider }
return .text return .text
} }
} }
+16 -3
View File
@@ -1,12 +1,25 @@
struct PHConfig: Decodable { struct PHConfig: Decodable {
let theme: String let theme: String
let window: String let window: String
let blocks: String? let layout: String
let env: [String: String]?
let monitors: [String: PHConfigMonitorOverride]? let monitors: [String: PHConfigMonitorOverride]?
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case theme, window, blocks, env case theme, window, layout
case monitors = "monitor" case monitors = "monitor"
} }
var layouts: Set<String> {
var layouts = Set<String>()
layouts.insert(layout)
guard let monitors else { return layouts }
for monitor in monitors.values {
if let layout = monitor.layout {
layouts.insert(layout)
}
}
return layouts
}
} }
@@ -1,18 +0,0 @@
import Foundation
extension PHConfig {
/// Return a copy with each `[env]` value expanded against `environment`
/// (defaulting to the process environment).
///
/// Unresolved references are left intact (see `PHEnvironment.expand`).
func resolvingEnv(in environment: PHEnvironment = .process) -> PHConfig {
guard let env, !env.isEmpty else { return self }
return PHConfig(
theme: theme,
window: window,
blocks: blocks,
env: env.mapValues(environment.expand),
monitors: monitors
)
}
}
@@ -2,58 +2,41 @@ import Foundation
import TOML import TOML
extension PHConfig { extension PHConfig {
/// Load configuration from the resolved config directory /// Load the configuration from the resolved config directory (see `PHPaths`).
/// (see `PHPaths`). If the file doesn't exist or fails to parse, the /// If the file doesn't exist or fails to parse, the execution is interrupted.
/// bundled default is used.
static func load() throws -> PHConfig { static func load() throws -> PHConfig {
let url = PHPaths.configDirectory.appending(path: "config.toml") let url = PHPaths.configFile
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url) return try load(from: url)
} else { } else {
return try loadFromBundle() let config = url.lastPathComponent
throw PHBar.Error(
"""
Configuration file not found.
Make sure to have a file named `\(config)` inside the
directory: `\(PHPaths.configDirectory.relativePath)`
Tip: If you've never used phbar before, run the `phbar install` command
to automatically generate the required configuration files.
"""
)
} }
} }
/// Load the bundled configuration. /// Load the configuration from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func loadFromBundle() throws -> PHConfig {
guard
let defaultConfig = Bundle.module.url(
forResource: "config",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load config file")
}
return try load(from: defaultConfig)
}
/// Decode configuration from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted. /// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHConfig { static private func load(from url: URL) throws -> PHConfig {
guard let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8)
else {
throw phbar.Error("Failed to load config file")
}
return try load(from: contents)
}
/// Decode configuration from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static private func load(from contents: String) throws -> PHConfig {
do { do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder() let decoder = TOMLDecoder()
let config = try decoder.decode(PHConfig.self, from: contents) let configFile = contents.expanded(from: ProcessInfo.processInfo.environment)
// Expand `$VAR` references in `[env]` values against the process return try decoder.decode(PHConfig.self, from: configFile)
// environment (e.g. `PATH = "$PATH:/opt/homebrew/bin"`). Themes are
// decoded against the merged result later, so their `$VAR` references
// resolve the same set.
return config.resolvingEnv()
} catch { } catch {
throw phbar.Error("Failed to parse config file", underlyingError: error) throw PHBar.Error("Configuration file not readable", underlyingError: error)
} }
} }
} }
@@ -3,25 +3,20 @@ import Foundation
/// A per-monitor entry from the config's `[monitor]` table. /// A per-monitor entry from the config's `[monitor]` table.
/// ///
/// Either field is optional; an unset field inherits the matching global /// Either field is optional; an unset field inherits the matching global
/// (`theme`, `window`, or `blocks`) so a monitor can override just one of them. /// (`window` or `layout`) so a monitor can override just one of them.
struct PHConfigMonitorOverride: Decodable { struct PHConfigMonitorOverride: Decodable {
let theme: String?
let window: String? let window: String?
let blocks: String? let layout: String?
init(theme: String? = nil, window: String? = nil, blocks: String? = nil) { init(window: String? = nil, layout: String? = nil) {
self.theme = theme
self.window = window self.window = window
self.blocks = blocks self.layout = layout
} }
} }
extension PHConfig { extension PHConfig {
/// The per-monitor override matching the given screen identity, if any. /// The per-monitor override matching the given screen identity, if any.
/// ///
/// This is the single entry point for per-monitor resolution: the
/// `theme`/`window`/`blocks` helpers below all delegate to it. Kept free of
/// AppKit so the resolution logic is testable without an `NSScreen`.
/// Precedence: monitor name monitor index. /// Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? { func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
guard let monitors else { return nil } guard let monitors else { return nil }
@@ -30,21 +25,15 @@ extension PHConfig {
return nil return nil
} }
/// Theme name for a screen: the override's `theme` if set, otherwise the /// Window name for a screen: the override's `window` if set,
/// global `theme`.
func theme(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.theme ?? theme
}
/// Window definition name for a screen: the override's `window` if set,
/// otherwise the global `window`. /// otherwise the global `window`.
func window(screenName: String, screenIndex: Int?) -> String { func window(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
} }
/// Block-set name for a screen: the override's `blocks` if set, otherwise /// Layout name for a screen: the override's `layout` if set,
/// the global `blocks` (defaulting to `"default"`). /// otherwise the global `layout`.
func blocks(screenName: String, screenIndex: Int?) -> String { func layout(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default") monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
} }
} }
-37
View File
@@ -1,37 +0,0 @@
# Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - window: a [[window]] definition from the theme file
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml
theme = "default"
window = "default"
blocks = "default"
# Optional: override the theme, window, and/or block set per monitor.
# - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value.
#
# [monitor."1"]
# theme = "default"
# window = "bottom"
# blocks = "laptop"
#
# [monitor."DELL U2723QE"]
# theme = "external"
# window = "clock"
# blocks = "external"
# Optional: environment variables passed to block scripts when they run.
# - scripts always inherit phbar's own process environment (e.g. $PATH)
# - entries listed here take precedence, overriding any inherited value
# that shares the same name
# - `$VAR` / `${VAR}` references in a value expand against the inherited
# environment at load time, so you can extend variables (e.g.
# `PATH = "$PATH:/opt/homebrew/bin"`) or pull in colors/fonts used by a
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
#
# [env]
# TEST = "ciao"
# TEST_2 = "hello world"
# PATH = "$PATH:/opt/homebrew/bin"
-76
View File
@@ -1,76 +0,0 @@
import Foundation
/// A resolved set of environment variables with `$VAR` expansion support.
///
/// Two concerns meet here:
///
/// 1. **Scripts** block commands run with these variables in their
/// environment, built by layering the `[env]` section on top of phbar's own
/// process environment (see `PHBlock.compute`).
/// 2. **Config templating** `@EnvExpanded` fields (e.g. theme colors, fonts)
/// expand `$VAR` references against a `PHEnvironment` injected through
/// `TOMLDecoder.userInfo` at decode time.
///
/// The environment is therefore resolved once (process merged with `[env]`)
/// and reused for both, so a variable defined in `[env]` is a single source of
/// truth visible to scripts and themes alike.
struct PHEnvironment: Sendable {
let variables: [String: String]
/// phbar's own process environment.
static let process = PHEnvironment(variables: ProcessInfo.processInfo.environment)
/// `CodingUserInfoKey` used to thread a `PHEnvironment` into `Decodable`
/// types (and the `@EnvExpanded` wrapper) without global state.
static let userInfoKey = CodingUserInfoKey(rawValue: "phbar.environment")!
/// Layer `overrides` (typically the `[env]` section) on top of this set.
/// Existing values with the same name are replaced.
func merging(_ overrides: [String: String]?) -> PHEnvironment {
guard let overrides, !overrides.isEmpty else { return self }
return PHEnvironment(variables: variables.merging(overrides) { _, override in override })
}
/// Expand `$VAR` and `${VAR}` references against `variables`.
///
/// Unresolved references are left intact rather than blanked, so a typo
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
/// vanishing.
func expand(_ value: String) -> String {
var result = value
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
variables[String(match.1)] ?? String(match.0)
}
// `$VAR`
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
variables[String(match.1)] ?? String(match.0)
}
return result
}
}
/// Opt-in property wrapper that expands `$VAR` / `${VAR}` references in a
/// decoded string against the `PHEnvironment` carried in `decoder.userInfo`
/// (falling back to the process environment when none is injected).
///
/// Apply only where config templating is wanted never on `command`, whose
/// `$`-references belong to the shell at run time, not to phbar at load time.
@propertyWrapper
struct EnvExpanded: Decodable, Equatable {
private let value: String
var wrappedValue: String { value }
init(wrappedValue: String) {
self.value = wrappedValue
}
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
let environment = (decoder.userInfo[PHEnvironment.userInfoKey] as? PHEnvironment) ?? .process
self.value = environment.expand(raw)
}
}
+7
View File
@@ -0,0 +1,7 @@
struct PHLayout: Decodable {
let blocks: [PHBlock]
private enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
@@ -0,0 +1,55 @@
import Foundation
import TOML
extension PHLayout {
/// Load the specified layouts from the resolved config directory (see `PHPaths`).
/// If any of theme doesn't exist or fails to parse, the execution is interrupted.
static func load(_ layouts: Set<String>) throws -> [String: PHLayout] {
var dictionary = [String: PHLayout]()
for layout in layouts {
dictionary.updateValue(try load(layout), forKey: layout)
}
return dictionary
}
/// Load a layout from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(_ layout: String) throws -> PHLayout {
let url = PHPaths.layoutsDirectory.appending(path: "\(layout).toml")
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
let layout = url.lastPathComponent
throw PHBar.Error(
"""
Layout file not found.
Make sure to have a file named `\(layout)` inside the
directory: `\(PHPaths.layoutsDirectory.relativePath)`
Tip: If you've never used phbar before, run `phbar generate config`
to automatically generate the required configuration files.
"""
)
}
}
/// Load a layout from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHLayout {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder()
let layoutFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHLayout.self, from: layoutFile)
} catch {
let layout = url.lastPathComponent
throw PHBar.Error("Layout file `\(layout)` not readable", underlyingError: error)
}
}
}
+15
View File
@@ -16,6 +16,21 @@ enum PHPaths {
/// The resolved configuration directory (computed once, cached). /// The resolved configuration directory (computed once, cached).
static let configDirectory: URL = resolveConfigDirectory() static let configDirectory: URL = resolveConfigDirectory()
/// The resolved config file (computed once, cached).
static let configFile: URL = {
configDirectory.appending(path: "config.toml")
}()
/// The resolved themes directory (computed once, cached).
static let themesDirectory: URL = {
configDirectory.appending(path: "themes")
}()
/// The resolved layouts directory (computed once, cached).
static let layoutsDirectory: URL = {
configDirectory.appending(path: "layouts")
}()
/// Ordered candidate directories, priority high low. /// Ordered candidate directories, priority high low.
/// ///
/// - Parameter environment: Override the environment lookup (used by tests); /// - Parameter environment: Override the environment lookup (used by tests);
+31
View File
@@ -0,0 +1,31 @@
import AppKit
/// Which screens to keep, remove, or add when reconciling live bars against the
/// current `NSScreen.screens`.
///
/// Pure and identity-based (it never reads `NSScreen` properties), so it can be
/// unit-tested with any `[NSScreen]` without driving real window lifecycle.
enum PHScreenSync {
struct Diff: Equatable {
/// Screens that already have a bar and are still attached. Their window
/// frame is recomputed since geometry may have changed.
let kept: [NSScreen]
/// Screens whose bar must be torn down (monitor disconnected).
let removed: [NSScreen]
/// Screens with no bar yet that need one (monitor connected).
let added: [NSScreen]
}
/// Compare the screens now attached (`current`) against the screens currently
/// occupied by bars (`occupied`). A disconnected monitor reappears as a new
/// `NSScreen` instance, so it is reported as remove + add, never a keep.
static func diff(current: [NSScreen], occupied: [NSScreen]) -> Diff {
let currentIDs = Set(current.map(ObjectIdentifier.init))
let occupiedIDs = Set(occupied.map(ObjectIdentifier.init))
return Diff(
kept: occupied.filter { currentIDs.contains(ObjectIdentifier($0)) },
removed: occupied.filter { !currentIDs.contains(ObjectIdentifier($0)) },
added: current.filter { !occupiedIDs.contains(ObjectIdentifier($0)) }
)
}
}
@@ -2,60 +2,42 @@ import Foundation
import TOML import TOML
extension PHTheme { extension PHTheme {
/// Load theme from the resolved config directory (`PHPaths`/themes). /// Load a theme from the resolved config directory (see `PHPaths`).
/// Falls back to the bundled theme when the file is absent or parsing fails. /// If the file doesn't exist or fails to parse, the execution is interrupted.
/// static func load(_ theme: String) throws -> PHTheme {
/// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded` let url = PHPaths.themesDirectory.appending(path: "\(theme).toml")
/// fields (colors, fonts); defaults to the process environment.
static func load(_ theme: String?, environment: PHEnvironment = .process) throws -> PHTheme {
guard let theme else { return try loadFromBundle() }
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url, environment: environment) return try load(from: url)
} else { } else {
return try loadFromBundle() let theme = url.lastPathComponent
throw PHBar.Error(
"""
Theme file not found.
Make sure to have a file named `\(theme)` inside the
directory: `\(PHPaths.themesDirectory.relativePath)`
Tip: If you've never used phbar before, run the `phbar install` command
to automatically generate the required configuration files.
"""
)
} }
} }
/// Load the bundled theme. /// Load a theme from the file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted. /// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func loadFromBundle() throws -> PHTheme { static private func load(from url: URL) throws -> PHTheme {
guard
let defaultConfig = Bundle.module.url(
forResource: "theme",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load theme file")
}
return try load(from: defaultConfig, environment: .process)
}
/// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { guard let contents = String(data: data, encoding: .utf8) else {
throw phbar.Error("the file is empty") throw PHBar.Error("content is not UTF8 encoded.")
} }
return try load(from: contents, environment: environment)
} catch {
throw phbar.Error("Failed to load theme file", underlyingError: error)
}
}
/// Load theme from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static private func load(from contents: String, environment: PHEnvironment) throws -> PHTheme {
do {
let decoder = TOMLDecoder() let decoder = TOMLDecoder()
decoder.userInfo[PHEnvironment.userInfoKey] = environment let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHTheme.self, from: contents) return try decoder.decode(PHTheme.self, from: themeFile)
} catch { } catch {
throw phbar.Error("Failed to parse theme file", underlyingError: error) let theme = url.lastPathComponent
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
} }
} }
} }
@@ -10,7 +10,7 @@ struct PHThemeStyle: Decodable {
name: "_default", name: "_default",
foreground: .init(color: "#000000", alpha: nil), foreground: .init(color: "#000000", alpha: nil),
background: .init(color: "#ffffff", alpha: nil), background: .init(color: "#ffffff", alpha: nil),
padding: .init(leading: 12, trailing: 12), padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
corner: nil corner: nil
) )
}() }()
@@ -3,7 +3,7 @@ import SwiftUI
struct PHThemeStyleColor: Decodable, ShapeStyle { struct PHThemeStyleColor: Decodable, ShapeStyle {
typealias Resolved = SwiftUI.Color typealias Resolved = SwiftUI.Color
@EnvExpanded var color: String var color: String
let alpha: Double? let alpha: Double?
var uiColor: SwiftUI.Color { var uiColor: SwiftUI.Color {
@@ -1,8 +1,12 @@
struct PHThemeStylePadding: Decodable { struct PHThemeStylePadding: Decodable {
let top: Double?
let bottom: Double?
let leading: Double? let leading: Double?
let trailing: Double? let trailing: Double?
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case top
case bottom
case leading = "left" case leading = "left"
case trailing = "right" case trailing = "right"
} }
@@ -3,7 +3,7 @@ import SwiftUI
struct PHThemeText: Decodable { struct PHThemeText: Decodable {
let name: String let name: String
@EnvExpanded var fontFamily: String var fontFamily: String
let size: Double let size: Double
let weight: PHThemeTextWeight let weight: PHThemeTextWeight
let style: PHThemeTextStyle let style: PHThemeTextStyle
@@ -8,21 +8,42 @@ import ArgumentParser
import Foundation import Foundation
@main @main
struct phbar: ParsableCommand { struct PHBar: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "phbar", commandName: "phbar",
abstract: "Modular status bar for macOS.", abstract: "Modular status bar for macOS.",
discussion: """ discussion: """
pmenu reads a list of newline-separated items from stdin and presents them to the user. phbar renders a status bar on every visible monitor. Each status bar
When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates. is dynamically built from the given theme, window configuration,
Entering text will narrow the items to those matching the tokens in the input. and block set. Thanks to the modularity of phbar, each one of these
elements can be overwritten for any given monitor.
phbar relies on blocks to determine what is visible on a bar and how
it should look. Everything in phbar is a block, not just texts but
also spaces and dividers. Each block can have a different visual style,
refresh conditions, and content source. The content of a block, if any,
is computed by calling a shell script, giving you the maximum flexibility.
Run `phbar install` to create the required config directory filled with
a template to start with. The directory will be created in one of these
locations in order of priority: $XDG_CONFIG_HOME/phbar, ~/.config/phbar,
or ~/.phbar.
Edit `<config-directory>/blocks/default.toml` to customize the block set used
by default and `<config-directory>/themes/default.toml` to customize the look
and feel of the bar. Check out the full documentation to learn more about all
the possible customisations.
Run `phbar start` to launch the bar and verify the appearance and behaviour.
Once you're ready to go to production, you can create a launch agent that uses
the same command to start the bar automatically at login.
""", """,
version: "1.0.0", version: "1.0.0",
subcommands: [start.self] subcommands: [Start.self, Install.self]
) )
} }
extension phbar { extension PHBar {
struct Error: LocalizedError { struct Error: LocalizedError {
let message: String let message: String
let underlyingError: Swift.Error? let underlyingError: Swift.Error?
+3 -4
View File
@@ -16,17 +16,16 @@ final class BarController: ObservableObject {
blocks.filter { $0.centered == true } blocks.filter { $0.centered == true }
} }
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], environment: PHEnvironment = .process, debug: Bool) { init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
self.config = config self.config = config
self.screen = screen self.screen = screen
self.theme = theme self.theme = theme
self.window = Self.window(for: config, screen: screen, from: theme) self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = blocks self.blocks = layout.blocks
for block in blocks { for block in blocks {
block.text = text(for: block) block.text = text(for: block)
block.style = style(for: block) block.style = style(for: block)
block.environment = environment
block.debug = debug block.debug = debug
} }
} }
@@ -101,7 +100,7 @@ extension BarController {
/// Each block updates concurrently; the view refreshes as results arrive. /// Each block updates concurrently; the view refreshes as results arrive.
func refresh() { func refresh() {
for block in blocks { for block in blocks {
Task { await block.update() } block.resolve()
} }
} }
} }
+8 -4
View File
@@ -13,9 +13,11 @@ struct BarView: View {
ForEach(bar.arrangedBlocks) { block in ForEach(bar.arrangedBlocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
TextBlockView(block: block) TextView(block: block)
case .space: case .space:
SpaceBlockView(block: block) SpaceView(block: block)
case .divider:
DividerView(block: block)
} }
} }
} }
@@ -24,9 +26,11 @@ struct BarView: View {
ForEach(bar.centeredBlocks) { block in ForEach(bar.centeredBlocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
TextBlockView(block: block) TextView(block: block)
case .space: case .space:
SpaceBlockView(block: block) SpaceView(block: block)
case .divider:
DividerView(block: block)
} }
} }
} }
+7
View File
@@ -46,6 +46,13 @@ final class BarWindow: NSPanel {
} }
extension BarWindow { extension BarWindow {
/// Recompute and apply the frame from the controller's screen and theme.
/// Used when a screen's geometry changes (resolution, layout) so the bar
/// stays correctly anchored without rebuilding the whole window.
func recomputeFrame() {
setFrame(Self.computeFrame(from: barController), display: true)
}
/// Calculate window frame and position from the theme window definition, /// Calculate window frame and position from the theme window definition,
/// resolved against the controller's screen. /// resolved against the controller's screen.
static func computeFrame(from controller: BarController) -> NSRect { static func computeFrame(from controller: BarController) -> NSRect {
+35
View File
@@ -0,0 +1,35 @@
import SwiftUI
struct DividerView: View {
@ObservedObject var block: PHBlock
var width: CGFloat {
guard let arg = block.name.split(separator: " ").last else { return 1 }
guard let width = Double(arg) else { return 1 }
return CGFloat(width)
}
var body: some View {
ZStack {
Rectangle()
.foregroundStyle(block.style.background?.uiColor ?? .clear)
Group {
if let corner = block.style.corner {
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
} else {
Rectangle()
}
}
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
.frame(width: width)
.padding(.trailing, block.style.padding?.trailing ?? 0)
.padding(.leading, block.style.padding?.leading ?? 0)
.padding(.top, block.style.padding?.top ?? 0)
.padding(.bottom, block.style.padding?.bottom ?? 0)
.border(block.debug ? .blue : .clear)
}
.fixedSize(horizontal: true, vertical: false)
.border(block.debug ? .red : .clear)
}
}
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
struct SpaceBlockView: View { struct SpaceView: View {
@ObservedObject var block: PHBlock @ObservedObject var block: PHBlock
var width: CGFloat? { var width: CGFloat? {
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
struct TextBlockView: View { struct TextView: View {
@EnvironmentObject private var bar: BarController @EnvironmentObject private var bar: BarController
@ObservedObject var block: PHBlock @ObservedObject var block: PHBlock
@@ -33,6 +33,8 @@ struct TextBlockView: View {
} }
.padding(.trailing, block.style.padding?.trailing ?? 0) .padding(.trailing, block.style.padding?.trailing ?? 0)
.padding(.leading, block.style.padding?.leading ?? 0) .padding(.leading, block.style.padding?.leading ?? 0)
.padding(.top, block.style.padding?.top ?? 0)
.padding(.bottom, block.style.padding?.bottom ?? 0)
} }
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
.border(block.debug ? .red : .clear) .border(block.debug ? .red : .clear)
@@ -45,7 +47,7 @@ struct TextBlockView: View {
.scrollWheel, .scrollWheel,
]) { event in ]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) { if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent) block.resolve(with: gestureEvent)
} }
return event return event
} }
+21
View File
@@ -0,0 +1,21 @@
# Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - window: a [[window]] definition from the theme file
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default"
window = "default"
layout = "default"
# Optional: override the window and/or layout per monitor.
# - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value.
#
# [monitor."1"]
# window = "bottom"
# layout = "laptop"
#
# [monitor."DELL U2723QE"]
# window = "clock"
# layout = "external"
+31
View File
@@ -0,0 +1,31 @@
[[block]]
name = "version"
style = "elevated"
[[block]]
name = "_space 14"
[[block]]
name = "attribution"
command = 'echo "by Panini House"'
text = "italic"
[[block]]
name = "_space"
[[block]]
name = "gesture"
[[block]]
name = "_space 14"
[[block]]
name = "clock"
refresh = 1
style = "elevated"
[[block]]
name = "greetings"
command = 'echo "􀟱 Hi $USER", welcome to phbar!'
text = "italic"
centered = true
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "􀐬 $(date "+%a %d, %H:%M:%S")"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
text="Click me"
case $GESTURE in
"left_mouse_down") text="That's left mouse button!" ;;
"right_mouse_down") text="That's right mouse button!" ;;
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
"scroll_wheel") text="Yes, you can even scroll!" ;;
esac
echo "􀭆 $text"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "phbar v$(phbar --version)"
@@ -8,39 +8,29 @@ anchor = "top"
height = 30 height = 30
width = "100%" width = "100%"
[[window]]
name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
[[text]] [[text]]
name = "default" name = "default"
font = "Comic Code" font = "monospace"
size = 14 size = 14
weight = "regular" weight = "regular"
style = "normal" style = "normal"
offset = -0.5 offset = -1
[[text]] [[text]]
name = "italic" name = "italic"
font = "Comic Code" font = "monospace"
size = 14 size = 14
weight = "regular" weight = "regular"
style = "italic" style = "italic"
offset = -0.5 offset = -1
[[style]] [[style]]
name = "default" name = "default"
foreground = { color = "#aed3f3" } foreground = { color = "#aed3f3" }
background = { color = "#010408", alpha = 0.825 } background = { color = "#010408" }
[[style]] [[style]]
name = "floating" name = "elevated"
foreground = { color = "#aed3f3" }
[[style]]
name = "tinted"
foreground = { color = "#aed3f3" } foreground = { color = "#aed3f3" }
background = { color = "#0f304a" } background = { color = "#0f304a" }
padding = { left = 12.0, right = 12.0 } padding = { left = 12.0, right = 12.0 }
+78
View File
@@ -0,0 +1,78 @@
import AppKit
import Foundation
import Testing
@testable import phbar
@MainActor
struct AppDelegateTests {
// MARK: - ScreenSync.diff
@Test func diffReportsAddedWhenNothingOccupied() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [screen], occupied: [])
#expect(plan.added == [screen])
#expect(plan.removed.isEmpty)
#expect(plan.kept.isEmpty)
}
@Test func diffReportsRemovedWhenScreenGone() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [], occupied: [screen])
#expect(plan.removed == [screen])
#expect(plan.added.isEmpty)
#expect(plan.kept.isEmpty)
}
@Test func diffKeepsScreenStillAttached() throws {
let screen = try #require(NSScreen.main)
let plan = PHScreenSync.diff(current: [screen], occupied: [screen])
#expect(plan.kept == [screen])
#expect(plan.added.isEmpty)
#expect(plan.removed.isEmpty)
}
/// A disconnected monitor reappears as a *new* `NSScreen` instance, so the
/// same physical display must be reported as remove + add, never a keep.
@Test func diffTreatsDistinctInstancesAsRemoveThenAdd() throws {
let main = try #require(NSScreen.main)
guard let other = NSScreen.screens.first(where: { $0 !== main }) else {
// Single-monitor environment: nothing to contrast against. Verify
// identity semantics hold for the same object re-presented instead.
let kept = PHScreenSync.diff(current: [main], occupied: [main])
#expect(kept.kept == [main])
return
}
let plan = PHScreenSync.diff(current: [other], occupied: [main])
#expect(plan.removed == [main])
#expect(plan.added == [other])
#expect(plan.kept.isEmpty)
}
@Test func diffIgnoresOrdering() throws {
let main = try #require(NSScreen.main)
guard let other = NSScreen.screens.first(where: { $0 !== main }) else {
// Single-monitor environment: ordering is trivially stable.
return
}
let plan = PHScreenSync.diff(current: [other, main], occupied: [main, other])
#expect(Set(plan.kept.map(ObjectIdentifier.init)) == Set([main, other].map(ObjectIdentifier.init)))
#expect(plan.added.isEmpty)
#expect(plan.removed.isEmpty)
}
// MARK: - BarControllerFactory
@Test func factoryBuildsControllerForScreen() throws {
let screen = try #require(NSScreen.main)
let config = try PHConfig.load()
let factory = PHBarFactory(config: config, environment: .process, debug: false)
let controller = try factory.make(for: screen)
#expect(controller.screen === screen)
#expect(controller.blocks.isEmpty == false)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import Testing
// MARK: - Refresh command // MARK: - Refresh command
@Test func refreshCommandConfigurationIsCorrect() async throws { @Test func refreshCommandConfigurationIsCorrect() async throws {
let config = phbar.refresh.configuration let config = PHBar.Refresh.configuration
#expect(config.commandName == "refresh") #expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.") #expect(config.abstract == "Refresh the running status bar.")
} }
+11 -11
View File
@@ -28,19 +28,19 @@ private func makeConfig(
} }
@Test func windowMatchesByIndex() { @Test func windowMatchesByIndex() {
let config = makeConfig(monitors: ["1": .init(window: "bottom", blocks: nil)]) let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)])
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom") #expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
} }
@Test func windowMatchesByName() { @Test func windowMatchesByName() {
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)]) let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)])
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock") #expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
} }
@Test func windowPrefersNameOverIndex() { @Test func windowPrefersNameOverIndex() {
let config = makeConfig(monitors: [ let config = makeConfig(monitors: [
"0": .init(window: "byIndex", blocks: nil), "0": .init(window: "byIndex", layout: nil),
"DELL": .init(window: "byName", blocks: nil), "DELL": .init(window: "byName", layout: nil),
]) ])
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName") #expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
} }
@@ -48,17 +48,17 @@ private func makeConfig(
// MARK: blocks // MARK: blocks
@Test func blocksDefaultsToDefault() { @Test func blocksDefaultsToDefault() {
#expect(makeConfig().blocks(screenName: "S", screenIndex: 0) == "default") #expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
} }
@Test func blocksUsesGlobalWhenNoOverride() { @Test func blocksUsesGlobalWhenNoOverride() {
let config = makeConfig(blocks: "main") let config = makeConfig(blocks: "main")
#expect(config.blocks(screenName: "S", screenIndex: 0) == "main") #expect(config.layout(screenName: "S", screenIndex: 0) == "main")
} }
@Test func blocksOverrideBeatsGlobal() { @Test func blocksOverrideBeatsGlobal() {
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, blocks: "alt")]) let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
#expect(config.blocks(screenName: "S", screenIndex: 1) == "alt") #expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
} }
// MARK: theme // MARK: theme
@@ -77,10 +77,10 @@ private func makeConfig(
@Test func overrideInheritsUnsetFieldsFromGlobal() { @Test func overrideInheritsUnsetFieldsFromGlobal() {
// An override that sets only `blocks` still inherits the global window/theme. // An override that sets only `blocks` still inherits the global window/theme.
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, blocks: "laptop")]) let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")])
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage") #expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top") #expect(config.window(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop") #expect(config.layout(screenName: "S", screenIndex: 1) == "laptop")
} }
@Test func themeWindowAndBlocksResolveIndependently() { @Test func themeWindowAndBlocksResolveIndependently() {
@@ -107,6 +107,6 @@ private func makeConfig(
} }
@Test func monitorOverrideReturnsNilForUnmatchedScreen() { @Test func monitorOverrideReturnsNilForUnmatchedScreen() {
let config = makeConfig(monitors: ["DELL": .init(window: "clock", blocks: nil)]) let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)])
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil) #expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
} }
-147
View File
@@ -1,147 +0,0 @@
import Foundation
import Testing
import TOML
@testable import phbar
// MARK: - expand
@Test func envExpandDollarBraceForm() {
let env = PHEnvironment(variables: ["ACCENT": "#ff0000"])
#expect(env.expand("${ACCENT}") == "#ff0000")
}
@Test func envExpandDollarForm() {
let env = PHEnvironment(variables: ["FOO": "bar"])
#expect(env.expand("$FOO") == "bar")
}
@Test func envExpandsEmbeddedReferences() {
let env = PHEnvironment(variables: ["HOME": "/Users/tom", "BIN": "/opt/bin"])
#expect(env.expand("$HOME/bin:${BIN}/extra") == "/Users/tom/bin:/opt/bin/extra")
}
@Test func envExpandLeavesUnresolvedIntact() {
// Unresolved references stay literal instead of being blanked, so a typo
// remains visible.
let env = PHEnvironment(variables: [:])
#expect(env.expand("$MISSING and ${ALSO_MISSING}") == "$MISSING and ${ALSO_MISSING}")
}
@Test func envExpandIgnoresNonIdentifierDollar() {
let env = PHEnvironment(variables: ["FOO": "bar"])
// A `$` not followed by an identifier name is left untouched.
#expect(env.expand("cost: $5 and $FOO") == "cost: $5 and bar")
}
// MARK: - merging
@Test func envMergingOverridesExistingKeys() {
let env = PHEnvironment(variables: ["PATH": "/usr/bin", "HOME": "/u"])
let merged = env.merging(["PATH": "/usr/bin:/extra"])
#expect(merged.variables["PATH"] == "/usr/bin:/extra")
#expect(merged.variables["HOME"] == "/u")
}
@Test func envMergingNilReturnsSameVariables() {
let env = PHEnvironment(variables: ["A": "1"])
#expect(env.merging(nil).variables == ["A": "1"])
}
// MARK: - [env] value expansion (config load)
private func makeConfig(env: [String: String]?) -> PHConfig {
PHConfig(theme: "default", window: "default", blocks: nil, env: env, monitors: nil)
}
@Test func resolvingEnvExpandsValuesAgainstGivenEnvironment() {
// `PATH = "$PATH:/extra"` extends an inherited variable.
let config = makeConfig(env: [
"PATH": "$PATH:/opt/phbar/bin",
"WORKDIR": "${TMPDIR}/phbar",
"PLAIN": "literal",
])
let resolved = config.resolvingEnv(in: PHEnvironment(variables: [
"PATH": "/usr/bin",
"TMPDIR": "/var/tmp",
]))
#expect(resolved.env?["PATH"] == "/usr/bin:/opt/phbar/bin")
#expect(resolved.env?["WORKDIR"] == "/var/tmp/phbar")
#expect(resolved.env?["PLAIN"] == "literal")
}
@Test func resolvingEnvDefaultsToProcessEnvironment() {
// `$TMPDIR` is set in phbar's launch environment, so this also exercises
// real process-env expansion end to end.
guard let tmpdir = ProcessInfo.processInfo.environment["TMPDIR"] else { return }
let config = makeConfig(env: ["OUT": "$TMPDIR"])
let resolved = config.resolvingEnv()
#expect(resolved.env?["OUT"] == tmpdir)
}
@Test func resolvingEnvLeavesConfigUntouchedWhenEnvAbsent() {
let config = makeConfig(env: nil)
#expect(config.resolvingEnv(in: .process).env == nil)
}
// MARK: - @EnvExpanded wrapper
/// Stand-in Decodable that mirrors how a theme field opts into expansion.
private struct Color: Decodable {
@EnvExpanded var hex: String
}
@Test func envExpandedResolvesAgainstInjectedEnvironment() throws {
let decoder = TOMLDecoder()
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
let color = try decoder.decode(Color.self, from: #"hex = "$ACCENT""#)
#expect(color.hex == "#ff0000")
}
@Test func envExpandedFallsBackToLaunchEnvironment() throws {
// Without injected userInfo the wrapper falls back to phbar's launch
// environment (`ProcessInfo`), which is a snapshot taken at process start.
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return }
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$PATH""#)
#expect(color.hex == path)
}
@Test func envExpandedLeavesUnresolvedIntact() throws {
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$NEVER_SET""#)
#expect(color.hex == "$NEVER_SET")
}
// MARK: - Theme integration
@Test func themeStyleColorExpandsEnvReference() throws {
let decoder = TOMLDecoder()
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
let style = try decoder.decode(PHThemeStyleColor.self, from: #"color = "$ACCENT""#)
#expect(style.color == "#ff0000")
}
@Test func themeTextFontExpandsEnvReference() throws {
let decoder = TOMLDecoder()
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["FONT": "Comic Code"])
let text = try decoder.decode(
PHThemeText.self,
from: """
name = "default"
font = "$FONT"
size = 14
weight = "regular"
style = "normal"
"""
)
#expect(text.fontFamily == "Comic Code")
}