Compare commits

...

26 Commits

Author SHA1 Message Date
tommaso f70ea7f0f5 remove hard-coded layout names 2026-07-14 00:36:43 +02:00
tommaso 4a74cf04c8 block running make as sudo 2026-07-14 00:35:49 +02:00
tommaso 3e9557586b greatly simplify environment variables handling 2026-07-13 23:46:08 +02:00
tommaso f479499309 refactor block resolve and refresh 2026-07-13 23:05:08 +02:00
tommaso 30be4b2901 wrap blocks around a layout concept 2026-07-13 21:08:04 +02:00
tommaso ed01251ca5 update Makefile 2026-07-13 16:53:48 +02:00
tommaso 1867a4c0a6 convert bundled defaults into an installable configuration template 2026-07-13 16:44:50 +02:00
tommaso a894449d15 improve error messages 2026-07-13 16:43:52 +02:00
tommaso a49f78cbb0 make theme persistent across monitors 2026-07-13 16:43:05 +02:00
tommaso 1a2eddcd5d refactor CLI 2026-07-13 11:43:37 +02:00
tommaso 909d890837 improve cli documentation 2026-07-13 11:41:25 +02:00
tommaso 87070dd62f improve divider 2026-07-12 21:39:29 +02:00
tommaso 39457d8faf add support for corner radius in dividers 2026-07-12 21:31:09 +02:00
tommaso a963a19bf3 add new divider block kind 2026-07-12 21:29:57 +02:00
tommaso aa241148ef add support for vertical padding in styles 2026-07-12 21:29:42 +02:00
tommaso 07a05e5470 rename views 2026-07-12 21:11:55 +02:00
tommaso 94c83d74a4 format code 2026-07-12 21:09:03 +02:00
tommaso 33c4cbe26e cleanup delegate 2026-07-12 19:57:33 +02:00
tommaso 3c5222d800 refresh the bars on screen changes 2026-07-12 19:51:42 +02:00
tommaso 27fad1d67c allow env variables expansion in config files 2026-07-12 18:20:03 +02:00
tommaso 9a7710d675 make name the core block info 2026-07-12 18:19:41 +02:00
tommaso f5e1ba1104 add support for corner radius 2026-07-12 17:05:29 +02:00
tommaso 77717c9654 rename leading and trailing externally in left and right 2026-07-12 16:50:18 +02:00
tommaso d4d1b7bec4 cleanup tests 2026-07-12 16:16:46 +02:00
tommaso b872659796 cleanup code 2026-07-12 16:16:35 +02:00
tommaso 0ff1664974 make Theme monitor dependent 2026-07-12 15:59:04 +02:00
57 changed files with 1885 additions and 1214 deletions
+38 -13
View File
@@ -1,47 +1,69 @@
# Makefile for phbar
#
# 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
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
# Build all targets in release mode
build:
$(guard_not_root)
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
@mkdir -p $(BIN_DIR)
@for bin in $(BINARIES); do \
cp -f $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
done
@if [ -w "$(BIN_DIR)" ]; then CP="cp -f"; else CP="sudo cp -f"; fi; \
for bin in $(BINARIES); do \
$$CP $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
done
@echo "✅ Installed $(BINARIES) to $(BIN_DIR)"
# Clean build artifacts
clean:
$(guard_not_root)
swift package clean
# Run tests
test:
$(guard_not_root)
swift test
# Quick test: build, install, and verify
run: install
@echo "Verifying installation..."
@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
# Uninstall binaries from ~/.local/bin
# Uninstall binaries. Elevates only the remove when BIN_DIR isn't writable.
uninstall:
@for bin in $(BINARIES); do \
rm -f $(BIN_DIR)/$$bin; \
done
@if [ -w "$(BIN_DIR)" ]; then RM="rm -f"; else RM="sudo rm -f"; fi; \
for bin in $(BINARIES); do \
$$RM $(BIN_DIR)/$$bin; \
done
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
# Rebuild and reinstall (clean + install)
@@ -51,9 +73,12 @@ reinstall: clean install
help:
@echo "Available targets:"
@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 test - Run tests"
@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 ""
@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: [
"Models/PHEvent/README.md"
],
resources: [
.process("Models/PHConfig/config.toml"),
.process("Models/PHTheme/theme.toml"),
]
resources: [.copy("config")]
),
.testTarget(
name: "phbarTests",
-63
View File
@@ -1,63 +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
/// window and block set from the config, so refresh state and layout stay
/// independent across monitors.
init(
config: PHConfig,
screens: [NSScreen],
theme: PHTheme,
debug: Bool
) throws {
super.init()
for screen in screens {
let blocks = try PHBlock.load(config.blocks(for: screen))
controllers.append(
BarController(
config: config,
screen: screen,
theme: theme,
blocks: blocks,
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()
}
}
-43
View File
@@ -1,43 +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()
let theme = try PHTheme.load("voltage")
// 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,
theme: theme,
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
extension phbar {
struct refresh: ParsableCommand {
extension PHBar {
struct Refresh: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "refresh",
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
)
}
}
+7 -4
View File
@@ -4,8 +4,7 @@ import Foundation
final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID()
let command: String
let name: String?
let name: String
let textName: String?
@Published var text: PHThemeText = .default
let styleName: String?
@@ -14,6 +13,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let centered: Bool?
var debug: Bool = false
nonisolated var command: String {
PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
}
var visible: Bool {
label != nil && !label!.isEmpty
}
@@ -36,10 +39,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
/// Guards against stacking concurrent updates during rapid event bursts
/// (e.g. dragging the volume slider fires many events in quick succession).
var updateScheduled = false
var resolveScheduled = false
private enum CodingKeys: String, CodingKey {
case command, name
case name
case textName = "text"
case styleName = "style"
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()
// Immediate refresh so the bar isn't blank until the first trigger fires.
scheduleUpdate()
resolve()
// 1. Periodic refresh.
if let interval = refresh, interval > 0 {
@@ -28,7 +28,7 @@ extension PHBlock {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds))
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.
for event in (events ?? []) {
let subscription = registry.subscribe(event) { [weak self] in
self?.scheduleUpdate()
self?.resolve()
}
subscriptions.append(subscription)
}
@@ -50,78 +50,4 @@ extension PHBlock {
subscriptions.forEach { $0.cancel() }
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 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]
// Inherit the parent's environment (includes $PATH, etc.)
process.environment = ProcessInfo.processInfo.environment
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 {
case text, space
case text, space, divider
}
extension PHBlock {
var kind: PHBlockKind {
if command.starts(with: "_space") { return .space }
if name.starts(with: "_space") { return .space }
if name.starts(with: "_divider") { return .divider }
return .text
}
}
+14 -32
View File
@@ -1,43 +1,25 @@
struct PHConfig: Decodable {
let theme: String
let window: String
let blocks: String?
let env: [String: String]?
let monitors: [String: PHMonitorOverride]?
let layout: String
let monitors: [String: PHConfigMonitorOverride]?
private enum CodingKeys: String, CodingKey {
case window, blocks, env
case theme, window, layout
case monitors = "monitor"
}
}
/// A per-monitor entry from the config's `[monitor]` table.
///
/// Either field is optional; an unset field inherits the matching global
/// (`window` or `blocks`) so a monitor can override just one of the two.
struct PHMonitorOverride: Decodable {
let window: String?
let blocks: String?
}
var layouts: Set<String> {
var layouts = Set<String>()
layouts.insert(layout)
extension PHConfig {
/// The override matching a screen identifier, if any.
/// Pure (no AppKit). Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHMonitorOverride? {
guard let monitors else { return nil }
if let byName = monitors[screenName] { return byName }
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
return nil
}
guard let monitors else { return layouts }
for monitor in monitors.values {
if let layout = monitor.layout {
layouts.insert(layout)
}
}
/// Window definition name for a screen (pure resolver).
/// Override's `window` if set, else the global `window`.
func windowName(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
}
/// Block-set name for a screen (pure resolver).
/// Override's `blocks` if set, else the global `blocks` (default `"default"`).
func blocksName(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default")
return layouts
}
}
@@ -2,54 +2,41 @@ import Foundation
import TOML
extension PHConfig {
/// Load configuration from the resolved config directory
/// (see `PHPaths`). If the file doesn't exist or fails to parse, the
/// bundled default is used.
/// Load the configuration from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load() throws -> PHConfig {
let url = PHPaths.configDirectory.appending(path: "config.toml")
let url = PHPaths.configFile
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} 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.
/// 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.
/// 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 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 {
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 configFile = try decoder.decode(PHConfig.self, from: contents)
return configFile
let configFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHConfig.self, from: configFile)
} catch {
throw phbar.Error("Failed to parse config file", underlyingError: error)
throw PHBar.Error("Configuration file not readable", underlyingError: error)
}
}
}
@@ -1,22 +0,0 @@
import AppKit
extension PHConfig {
/// The per-monitor override that applies to `screen`, if any.
/// Precedence: monitor name monitor index.
func monitorOverride(for screen: NSScreen) -> PHMonitorOverride? {
let index = NSScreen.screens.firstIndex(of: screen)
return monitorOverride(screenName: screen.localizedName, screenIndex: index)
}
/// Window definition name for `screen`: the override's `window` if set,
/// otherwise the global `window`.
func windowName(for screen: NSScreen) -> String {
monitorOverride(for: screen)?.window ?? window
}
/// Block-set name for `screen`: the override's `blocks` if set, otherwise
/// the global `blocks` (defaulting to `"default"`).
func blocks(for screen: NSScreen) -> String {
monitorOverride(for: screen)?.blocks ?? (blocks ?? "default")
}
}
@@ -0,0 +1,39 @@
import Foundation
/// A per-monitor entry from the config's `[monitor]` table.
///
/// Either field is optional; an unset field inherits the matching global
/// (`window` or `layout`) so a monitor can override just one of them.
struct PHConfigMonitorOverride: Decodable {
let window: String?
let layout: String?
init(window: String? = nil, layout: String? = nil) {
self.window = window
self.layout = layout
}
}
extension PHConfig {
/// The per-monitor override matching the given screen identity, if any.
///
/// Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
guard let monitors else { return nil }
if let byName = monitors[screenName] { return byName }
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
return nil
}
/// Window name for a screen: the override's `window` if set,
/// otherwise the global `window`.
func window(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
}
/// Layout name for a screen: the override's `layout` if set,
/// otherwise the global `layout`.
func layout(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
}
}
+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).
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.
///
/// - 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,56 +2,42 @@ import Foundation
import TOML
extension PHTheme {
/// Load theme from the resolved config directory (`PHPaths`/themes).
/// Falls back to the bundled theme when the file is absent or parsing fails.
static func load(_ theme: String?) throws -> PHTheme {
guard let theme else { return try loadFromBundle() }
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
/// Load a theme from the resolved config directory (see `PHPaths`).
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(_ theme: String) throws -> PHTheme {
let url = PHPaths.themesDirectory.appending(path: "\(theme).toml")
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} 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.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func loadFromBundle() 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)
}
/// Load theme from a file at URL.
/// Load a theme 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 -> PHTheme {
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")
guard let contents = String(data: data, encoding: .utf8) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
return try load(from: contents)
} 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) throws -> PHTheme {
do {
let decoder = TOMLDecoder()
return try decoder.decode(PHTheme.self, from: contents)
let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHTheme.self, from: themeFile)
} 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)
}
}
}
@@ -3,13 +3,15 @@ struct PHThemeStyle: Decodable {
let foreground: PHThemeStyleColor?
let background: PHThemeStyleColor?
let padding: PHThemeStylePadding?
let corner: PHThemeStyleCorner?
static let `default`: Self = {
PHThemeStyle(
name: "_default",
foreground: .init(color: "#000000", alpha: nil),
background: .init(color: "#ffffff", alpha: nil),
padding: .init(trailing: 12, leading: 12)
padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
corner: nil
)
}()
}
@@ -3,7 +3,7 @@ import SwiftUI
struct PHThemeStyleColor: Decodable, ShapeStyle {
typealias Resolved = SwiftUI.Color
let color: String
var color: String
let alpha: Double?
var uiColor: SwiftUI.Color {
@@ -0,0 +1,26 @@
import SwiftUI
struct PHThemeStyleCorner: Decodable {
let radius: Double
private let _style: PHThemeStyleCornerStyle?
var style: SwiftUI.RoundedCornerStyle {
switch _style {
case .circular:
return .circular
case .continuous:
return .continuous
default:
return .circular
}
}
private enum CodingKeys: String, CodingKey {
case radius
case _style = "style"
}
}
enum PHThemeStyleCornerStyle: String, Decodable {
case circular, continuous
}
@@ -1,9 +1,13 @@
struct PHThemeStylePadding: Decodable {
let trailing: Double?
let top: Double?
let bottom: Double?
let leading: Double?
let trailing: Double?
private enum CodingKeys: String, CodingKey {
case trailing = "right"
case top
case bottom
case leading = "left"
case trailing = "right"
}
}
@@ -3,7 +3,7 @@ import SwiftUI
struct PHThemeText: Decodable {
let name: String
let fontFamily: String
var fontFamily: String
let size: Double
let weight: PHThemeTextWeight
let style: PHThemeTextStyle
@@ -8,10 +8,10 @@ import Foundation
/// `center` centers the window on both axes.
enum PHThemeWindowAnchor: String, Decodable {
case top, bottom, leading, trailing
case topLeading = "top-leading"
case topTrailing = "top-trailing"
case bottomLeading = "bottom-leading"
case bottomTrailing = "bottom-trailing"
case topLeading = "top-left"
case topTrailing = "top-right"
case bottomLeading = "bottom-left"
case bottomTrailing = "bottom-right"
case center
}
@@ -13,6 +13,13 @@ struct PHThemeWindowMargin: Decodable, Equatable {
let leading: Double?
let trailing: Double?
private enum CodingKeys: String, CodingKey {
case top
case bottom
case leading = "left"
case trailing = "right"
}
init(
top: Double? = nil,
bottom: Double? = nil,
-47
View File
@@ -1,47 +0,0 @@
# phbar theme file ~ Voltage
#
# Place at ~/.config/phbar/themes/voltage.toml
[[window]]
name = "default"
anchor = "top"
height = 30
width = "100%"
margin = { leading = 30, top = 30, trailing = 30, bottom = 30 }
[[window]]
name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
[[text]]
name = "default"
font = "Comic Code"
size = 14
weight = "regular"
style = "normal"
offset = -0.5
[[text]]
name = "italic"
font = "Comic Code"
size = 14
weight = "regular"
style = "italic"
offset = -0.5
[[style]]
name = "default"
foreground = { color = "#aed3f3" }
background = { color = "#010408", alpha = 0.825 }
[[style]]
name = "floating"
foreground = { color = "#aed3f3" }
[[style]]
name = "tinted"
foreground = { color = "#aed3f3" }
background = { color = "#0f304a" }
padding = { left = 12.0, right = 12.0 }
@@ -8,21 +8,42 @@ import ArgumentParser
import Foundation
@main
struct phbar: ParsableCommand {
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.
phbar renders a status bar on every visible monitor. Each status bar
is dynamically built from the given theme, window configuration,
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",
subcommands: [start.self]
subcommands: [Start.self, Install.self]
)
}
extension phbar {
extension PHBar {
struct Error: LocalizedError {
let message: String
let underlyingError: Swift.Error?
+11 -6
View File
@@ -16,12 +16,12 @@ final class BarController: ObservableObject {
blocks.filter { $0.centered == true }
}
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool) {
init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
self.config = config
self.screen = screen
self.theme = theme
self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = blocks
self.blocks = layout.blocks
for block in blocks {
block.text = text(for: block)
@@ -35,10 +35,15 @@ final class BarController: ObservableObject {
extension BarController {
/// Resolve the window definition for a given screen, applying any
/// per-monitor override from the config. Selection itself lives on
/// `PHConfig` (see `windowName(for:)`).
/// per-monitor override from the config. Selection lives on `PHConfig`
/// (see `window(screenName:screenIndex:)`); this looks the name up in the
/// theme, falling back to "default".
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
resolve(window: config.windowName(for: screen), from: theme)
let name = config.window(
screenName: screen.localizedName,
screenIndex: NSScreen.screens.firstIndex(of: screen)
)
return resolve(window: name, from: theme)
}
/// Look up a window definition by name, falling back to "default" then the
@@ -95,7 +100,7 @@ extension BarController {
/// Each block updates concurrently; the view refreshes as results arrive.
func refresh() {
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
switch block.kind {
case .text:
TextBlockView(block: block)
TextView(block: block)
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
switch block.kind {
case .text:
TextBlockView(block: block)
TextView(block: block)
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 {
/// 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,
/// resolved against the controller's screen.
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,10 +1,10 @@
import SwiftUI
struct SpaceBlockView: View {
struct SpaceView: View {
@ObservedObject var block: PHBlock
var width: CGFloat? {
guard let arg = block.command.split(separator: " ").last else { return nil }
guard let arg = block.name.split(separator: " ").last else { return nil }
guard let width = Double(arg) else { return nil }
return CGFloat(width)
}
@@ -1,6 +1,6 @@
import SwiftUI
struct TextBlockView: View {
struct TextView: View {
@EnvironmentObject private var bar: BarController
@ObservedObject var block: PHBlock
@@ -14,8 +14,14 @@ struct TextBlockView: View {
var body: some View {
if let label = block.label, !label.isEmpty {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 0)
.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.background?.uiColor ?? .clear)
Group {
Text(label)
@@ -27,6 +33,8 @@ struct TextBlockView: View {
}
.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)
}
.fixedSize(horizontal: true, vertical: false)
.border(block.debug ? .red : .clear)
@@ -39,7 +47,7 @@ struct TextBlockView: View {
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent)
block.resolve(with: gestureEvent)
}
return event
}
@@ -1,10 +1,12 @@
# 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
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default"
window = "default"
blocks = "default"
layout = "default"
# Optional: override the window and/or block set per monitor.
# 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.
@@ -12,8 +14,8 @@ blocks = "default"
#
# [monitor."1"]
# window = "bottom"
# blocks = "laptop"
# layout = "laptop"
#
# [monitor."DELL U2723QE"]
# window = "clock"
# blocks = "external"
# 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)"
+36
View File
@@ -0,0 +1,36 @@
# phbar theme file
#
# Place at ~/.config/phbar/themes/default.toml
[[window]]
name = "default"
anchor = "top"
height = 30
width = "100%"
[[text]]
name = "default"
font = "monospace"
size = 14
weight = "regular"
style = "normal"
offset = -1
[[text]]
name = "italic"
font = "monospace"
size = 14
weight = "regular"
style = "italic"
offset = -1
[[style]]
name = "default"
foreground = { color = "#aed3f3" }
background = { color = "#010408" }
[[style]]
name = "elevated"
foreground = { color = "#aed3f3" }
background = { color = "#0f304a" }
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)
}
}
+41
View File
@@ -0,0 +1,41 @@
import AppKit
import Foundation
import Testing
@testable import phbar
// MARK: - Refresh command
@Test func refreshCommandConfigurationIsCorrect() async throws {
let config = PHBar.Refresh.configuration
#expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.")
}
// MARK: - BarController refresh
@MainActor
@Test func controllerRefreshUpdatesAllBlocks() async throws {
let screen = try #require(NSScreen.main)
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf a"
name = "a"
[[block]]
command = "printf b"
name = "b"
""")
let config = try PHConfig.load()
let theme = try PHTheme.load("voltage")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
controller.refresh()
// Allow the per-block update tasks to run.
try await Task.sleep(for: .milliseconds(100))
#expect(blocks[0].label == "a")
#expect(blocks[1].label == "b")
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Payload Types for Testing
private struct TestPayload: Codable, Equatable {
let message: String
let count: Int
}
// MARK: - Notification Name
@Test func notificationNameIsHashable() async throws {
let a = IPCNotificationName("com.example.test")
let b = IPCNotificationName("com.example.test")
let c = IPCNotificationName("com.example.other")
#expect(a == b)
#expect(a != c)
#expect(a.hashValue == b.hashValue)
}
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
let name: IPCNotificationName = "com.example.test"
#expect(name.rawValue == "com.example.test")
}
@Test func notificationNameRawRepresentable() async throws {
let name = IPCNotificationName(rawValue: "com.example.test")
#expect(name.rawValue == "com.example.test")
}
// MARK: - Payload Decoding
@Test func decodePayloadFromNotification() async throws {
let payload = TestPayload(message: "hello", count: 42)
let userInfo: [String: Any] = ["message": "hello", "count": 42]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == payload)
}
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
let notification = IPCNotification(name: .refresh, userInfo: nil)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
@Test func decodePayloadReturnsNilForInvalidData() async throws {
let userInfo: [String: Any] = ["wrong": "data"]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
// MARK: - Posting
@Test func postWithoutPayloadDoesNotCrash() async throws {
IPC.post(.refresh)
}
@Test func postWithUserInfoDoesNotCrash() async throws {
IPC.post(.refresh, userInfo: ["key": "value"])
}
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
let payload = TestPayload(message: "test", count: 1)
let result = IPC.post(.refresh, payload: payload)
#expect(result == true)
}
// MARK: - Observer
@Test func observerCleansUpOnDeinit() async throws {
// Verify that creating and releasing an observer doesn't crash.
// The token removes itself from the distributed center on deinit.
let token = IPC.observe(.refresh) { _ in }
withExtendedLifetime(token) {}
}
+189
View File
@@ -0,0 +1,189 @@
import AppKit
import Foundation
import Testing
@testable import phbar
// MARK: - Loading
@MainActor
@Test func loadBlocksFromTOML() throws {
let toml = """
[[block]]
command = "echo hello"
name = "greeting"
refresh = 5.0
[[block]]
command = "echo world"
name = "world"
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks.count == 2)
#expect(blocks[0].command == "echo hello")
#expect(blocks[0].name == "greeting")
#expect(blocks[0].refresh == 5.0)
#expect(blocks[0].label == nil)
#expect(blocks[1].name == "world")
#expect(blocks[1].refresh == nil)
}
@MainActor
@Test func loadBlocksRejectsInvalidTOML() {
#expect(throws: (any Error).self) {
try PHBlock.load(from: "not = valid = toml = =")
}
}
// MARK: - Named set loading
private func makeBlocksConfigDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@MainActor
@Test func loadSetReadsNamedFile() throws {
let dir = try makeBlocksConfigDir()
defer { try? FileManager.default.removeItem(at: dir) }
let blocksDir = dir.appending(path: "blocks")
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
try Data("""
[[block]]
command = "echo hi"
name = "greeting"
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
let blocks = try PHBlock.load("laptop", in: dir)
#expect(blocks.count == 1)
#expect(blocks[0].name == "greeting")
}
@MainActor
@Test func loadSetThrowsForMissingSet() {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
#expect(throws: (any Error).self) {
try PHBlock.load("nope", in: dir)
}
}
// MARK: - Compute
@MainActor
@Test func computeSeesBlockEnvironment() async throws {
// The `[env]` section (overlaid by `BarController`) must reach the script's
// process environment this is the wiring the `env` config documents.
let block = try PHBlock.load(from: """
[[block]]
name = "env"
command = "echo $PHBAR_TEST_VAR"
""")[0]
block.environment = PHEnvironment(variables: ["PHBAR_TEST_VAR": "panini"])
let output = await block.compute()
#expect(output == "panini")
}
@MainActor
@Test func computeReturnsCommandStdout() async throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf panini"
name = "panini"
""")
let output = await blocks[0].compute()
#expect(output == "panini")
}
@MainActor
@Test func computeRunsInConfigDirectory() async throws {
// Block commands execute with the resolved config root (see `PHPaths`) as
// their working directory, so a block from any set resolves relative paths
// the same way. Guards against the config dir being absent in sandboxed CIs.
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
let blocks = try PHBlock.load(from: """
[[block]]
command = "pwd"
name = "pwd"
""")
let output = await blocks[0].compute()
// Resolve symlinks on both sides: the config dir is commonly a symlink into
// a dotfiles repo, and `pwd` reports the physical path.
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
#expect(output == expected)
}
// MARK: - Auto-refresh
@MainActor
@Test func updateSetsLabel() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf panini"
name = "panini"
""")[0]
#expect(block.label == nil)
await block.update()
#expect(block.label == "panini")
}
@MainActor
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf hi"
name = "hi"
""")[0]
block.startAutoRefresh()
// Allow the one-shot update task to run.
try await Task.sleep(for: .milliseconds(100))
#expect(block.label == "hi")
block.stopAutoRefresh()
}
@MainActor
@Test func startAutoRefreshRepeatsAtInterval() async throws {
// A counter file lets us observe how many times the command ran.
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_test_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let toml = """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "counter"
refresh = 0.05
"""
let block = try PHBlock.load(from: toml)[0]
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(250))
block.stopAutoRefresh()
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
// At ~20Hz over 250ms the command should have run more than once.
#expect(count >= 2)
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Per-monitor resolution
//
// `PHConfig` exposes one resolution API: `theme`/`window`/`blocks`/
// `monitorOverride`, each taking a pure `(screenName, screenIndex)` pair.
// Precedence for every field: override-by-name override-by-index global.
/// Build a `PHConfig` with sensible defaults so each test only spells out the
/// fields it cares about.
private func makeConfig(
theme: String = "default",
window: String = "default",
blocks: String? = nil,
monitors: [String: PHConfigMonitorOverride]? = nil
) -> PHConfig {
PHConfig(theme: theme, window: window, blocks: blocks, env: nil, monitors: monitors)
}
// MARK: window
@Test func windowFallsBackToGlobal() {
let config = makeConfig(window: "top")
#expect(config.window(screenName: "Built-in", screenIndex: 0) == "top")
}
@Test func windowMatchesByIndex() {
let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)])
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
}
@Test func windowMatchesByName() {
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)])
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
}
@Test func windowPrefersNameOverIndex() {
let config = makeConfig(monitors: [
"0": .init(window: "byIndex", layout: nil),
"DELL": .init(window: "byName", layout: nil),
])
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
}
// MARK: blocks
@Test func blocksDefaultsToDefault() {
#expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
}
@Test func blocksUsesGlobalWhenNoOverride() {
let config = makeConfig(blocks: "main")
#expect(config.layout(screenName: "S", screenIndex: 0) == "main")
}
@Test func blocksOverrideBeatsGlobal() {
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
#expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
}
// MARK: theme
@Test func themeFallsBackToGlobal() {
let config = makeConfig(theme: "voltage")
#expect(config.theme(screenName: "S", screenIndex: 0) == "voltage")
}
@Test func themeOverrideBeatsGlobal() {
let config = makeConfig(theme: "voltage", monitors: ["1": .init(theme: "mono", window: nil, blocks: nil)])
#expect(config.theme(screenName: "S", screenIndex: 1) == "mono")
}
// MARK: independence
@Test func overrideInheritsUnsetFieldsFromGlobal() {
// An override that sets only `blocks` still inherits the global window/theme.
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")])
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
#expect(config.layout(screenName: "S", screenIndex: 1) == "laptop")
}
@Test func themeWindowAndBlocksResolveIndependently() {
let config = makeConfig(
theme: "voltage", window: "top", blocks: "main",
monitors: [
"DELL": .init(theme: "mono", window: "clock", blocks: nil), // inherits blocks "main"
"1": .init(theme: nil, window: nil, blocks: "laptop"), // inherits theme/window
]
)
#expect(config.theme(screenName: "DELL", screenIndex: 0) == "mono")
#expect(config.window(screenName: "DELL", screenIndex: 0) == "clock")
#expect(config.blocks(screenName: "DELL", screenIndex: 0) == "main")
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
}
// MARK: monitorOverride
@Test func monitorOverrideReturnsNilWhenTableAbsent() {
#expect(makeConfig().monitorOverride(screenName: "S", screenIndex: 0) == nil)
}
@Test func monitorOverrideReturnsNilForUnmatchedScreen() {
let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)])
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
}
+225
View File
@@ -0,0 +1,225 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Fake event source
/// A no-op source that records its lifecycle and can be fired on demand.
@MainActor
private final class FakeEventSource: PHEventSource {
private(set) var startCount = 0
private(set) var stopCount = 0
private var notify: (@MainActor @Sendable () -> Void)?
func start(notify: @escaping @MainActor @Sendable () -> Void) {
startCount += 1
self.notify = notify
}
func stop() {
stopCount += 1
notify = nil
}
func fire() { notify?() }
}
// MARK: - Event decoding
@MainActor
@Test func eventDecodesFromKnownStrings() throws {
let toml = """
[[block]]
command = "echo hi"
name = "a"
events = ["volume", "network", "appearance", "power", "mpd"]
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
}
@MainActor
@Test func eventAcceptsArbitraryStringAsCustom() throws {
// Unknown strings become custom event names resolved at runtime against
// `<config>/events/<name>/`.
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
name = "b"
events = ["totally_made_up"]
""")
#expect(blocks[0].events == [PHEvent("totally_made_up")])
}
@MainActor
@Test func eventsOptionalWhenOmitted() throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
name = "c"
""")
#expect(blocks[0].events == nil)
}
// MARK: - Event loader
@MainActor
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
// No recognizer installed at a clean directory factory yields nil.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
return PHExternalEventSource(recognizer: recognizer)
}
#expect(factory(PHEvent("anything")) == nil)
}
@MainActor
@Test func eventLoaderReturnsNilForMissingEvent() {
// A clean directory has no event folders, so every name fails to load.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
#expect(recognizer == nil)
}
// MARK: - Event registry
@MainActor
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
#expect(fake.startCount == 0)
#expect(fake.stopCount == 0)
var fired = 0
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
// A second subscriber must reuse the already-running source.
let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
// One firing fans out to both subscribers.
fake.fire()
#expect(fired == 2)
// Cancelling one keeps the source alive for the other.
s1.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 0)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
fake.fire()
#expect(fired == 3)
// Cancelling the last subscriber tears the source down.
s2.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
}
@MainActor
@Test func registryStartsSeparateSourcePerEvent() async throws {
let volumeFake = FakeEventSource()
let networkFake = FakeEventSource()
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
if event == PHEvent("volume") { return volumeFake }
return networkFake
}
let registry = PHEventRegistry(factory: factory)
let v = registry.subscribe(PHEvent("volume")) {}
let n = registry.subscribe(PHEvent("network")) {}
#expect(volumeFake.startCount == 1)
#expect(networkFake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
v.cancel()
n.cancel()
}
// MARK: - Block + events
@MainActor
@Test func blockRefreshesOnEvent() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_evt_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "d"
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
#expect(fake.startCount == 1)
// Firing the event triggers a second refresh.
fake.fire()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
// Stopping cancels the subscription and tears the source down.
block.stopAutoRefresh()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
}
@MainActor
@Test func blockCombinesIntervalAndEvents() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_combo_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
name = "e"
refresh = 0.2
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(50))
let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterInitial == 1)
#expect(fake.startCount == 1)
// Both an event and the interval can drive updates independently.
fake.fire()
try await Task.sleep(for: .milliseconds(50))
let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterEvent >= 2)
block.stopAutoRefresh()
}
+58
View File
@@ -0,0 +1,58 @@
import Foundation
import Testing
@testable import phbar
// MARK: - Config directory cascade
//
// `PHPaths` resolves the config root in priority order:
// 1. $XDG_CONFIG_HOME/phbar (only for an absolute XDG path)
// 2. ~/.config/phbar
// 3. ~/.phbar
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
#expect(candidates.count == 3)
#expect(candidates[0].path == "/custom/xdg/phbar")
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
#expect(candidates[2].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitRelativeXdg() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
#expect(candidates.count == 2)
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
#expect(candidates[1].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitEmptyXdg() {
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
#expect(candidates.count == 2)
}
@Test func pathsCandidatesOmitXdgWhenUnset() {
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
#expect(candidates.count == 2)
}
@Test func pathsResolvePicksFirstExistingCandidate() throws {
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
// it sorts first in the candidate list.
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
let xdgConfig = xdgRoot.appending(path: "phbar")
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: xdgRoot) }
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
#expect(resolved == xdgConfig)
}
@Test func pathsConfigDirectoryMatchesResolve() {
// The cached static agrees with a fresh resolve against the live process env.
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
}
+166
View File
@@ -0,0 +1,166 @@
import AppKit
import CoreGraphics
import Foundation
import Testing
@testable import phbar
// MARK: - PHThemeWindowDimension
private struct DimensionWrapper: Decodable {
let v: PHThemeWindowDimension
}
@Test func dimensionDecodesPoints() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
}
@Test func dimensionDecodesPercentage() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
}
@Test func dimensionRejectsGarbage() {
let decoder = JSONDecoder()
#expect(throws: (any Error).self) {
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
}
}
@Test func dimensionResolvesAgainstLength() {
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
}
// MARK: - PHThemeWindowAnchor geometry
@Test func anchorPlacesAtTopEdge() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
#expect(origin == CGPoint(x: 0, y: 770))
}
@Test func anchorRespectsTopMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 0, y: 760))
}
@Test func anchorPlacesBottomLeadingCorner() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 12, y: 8))
}
@Test func anchorTrailingCentersVertically() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersWithinContentRect() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
// `.center` centers within the inset rect: a top margin lifts the center,
// it doesn't ignore the margin.
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
// rect: y [0, 700], midY = 350 350 20 = 330
#expect(origin == CGPoint(x: 400, y: 330))
}
// MARK: - PHThemeWindowMargin.inset
@Test func marginInsetAppliesAllEdges() {
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
let inset = PHThemeWindowMargin(top: 10, bottom: 20, leading: 30, trailing: 40).inset(of: rect)
#expect(inset == CGRect(x: 30, y: 20, width: 930, height: 770))
}
@Test func marginInsetLeavesUnspecifiedEdgesUntouched() {
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
// Only leading set; top/bottom/trailing are left as-is (treated as 0).
let inset = PHThemeWindowMargin(leading: 50).inset(of: rect)
#expect(inset == CGRect(x: 50, y: 0, width: 950, height: 800))
}
// MARK: - BarWindow.computeFrame
@MainActor
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
let screen = try #require(NSScreen.main)
// A window that omits geometry exercises computeFrame's defaults:
// width 100%, top anchor, no margin a full-screen top bar. Built locally
// (rather than loading the bundled theme) so the test tracks the engine's
// defaults, not the theme file's editorial margins.
let theme = PHTheme(
windows: [PHThemeWindow(name: "default")],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == screen.frame.width)
#expect(frame.minX == screen.frame.minX)
#expect(frame.minY == screen.frame.maxY - 30)
}
@MainActor
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
let screen = try #require(NSScreen.main)
let theme = PHTheme(
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.origin == CGPoint(x: 100, y: 200))
#expect(frame.height == 30)
}
@MainActor
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
// Regression: `width = "100%"` previously ignored leading/trailing margins
// and spanned edge to edge. Margins now inset the content rectangle, so the
// bar spans only between them.
let screen = try #require(NSScreen.main)
let sf = screen.frame
let theme = PHTheme(
windows: [PHThemeWindow(
name: "inset",
width: .percentage(1.0),
anchor: .top,
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
)],
texts: nil,
styles: nil
)
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == sf.width - 40)
#expect(frame.minX == sf.minX + 20)
#expect(frame.maxX == sf.maxX - 20)
}
-746
View File
@@ -1,746 +0,0 @@
import AppKit
import Foundation
import Testing
@testable import phbar
// MARK: - Payload Types for Testing
private struct TestPayload: Codable, Equatable {
let message: String
let count: Int
}
// MARK: - Notification Name Tests
@Test func notificationNameIsHashable() async throws {
let a = IPCNotificationName("com.example.test")
let b = IPCNotificationName("com.example.test")
let c = IPCNotificationName("com.example.other")
#expect(a == b)
#expect(a != c)
#expect(a.hashValue == b.hashValue)
}
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
let name: IPCNotificationName = "com.example.test"
#expect(name.rawValue == "com.example.test")
}
@Test func notificationNameRawRepresentable() async throws {
let name = IPCNotificationName(rawValue: "com.example.test")
#expect(name.rawValue == "com.example.test")
}
// MARK: - Payload Decoding Tests
@Test func decodePayloadFromNotification() async throws {
let payload = TestPayload(message: "hello", count: 42)
let userInfo: [String: Any] = ["message": "hello", "count": 42]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == payload)
}
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
let notification = IPCNotification(name: .refresh, userInfo: nil)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
@Test func decodePayloadReturnsNilForInvalidData() async throws {
let userInfo: [String: Any] = ["wrong": "data"]
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
let decoded: TestPayload? = notification.decode(TestPayload.self)
#expect(decoded == nil)
}
// MARK: - Posting Tests
@Test func postWithoutPayloadDoesNotCrash() async throws {
IPC.post(.refresh)
}
@Test func postWithUserInfoDoesNotCrash() async throws {
IPC.post(.refresh, userInfo: ["key": "value"])
}
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
let payload = TestPayload(message: "test", count: 1)
let result = IPC.post(.refresh, payload: payload)
#expect(result == true)
}
// MARK: - Observer Tests
@Test func observerCleansUpOnDeinit() async throws {
// Verify that creating and releasing an observer doesn't crash.
// The token removes itself from the distributed center on deinit.
let token = IPC.observe(.refresh) { _ in }
withExtendedLifetime(token) {}
}
// MARK: - Refresh Command Config
@Test func refreshCommandConfigurationIsCorrect() async throws {
let config = phbar.refresh.configuration
#expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.")
}
// MARK: - PHBlock Loading & Compute
@MainActor
@Test func loadBlocksFromTOML() throws {
let toml = """
[[block]]
command = "echo hello"
name = "greeting"
refresh = 5.0
[[block]]
command = "echo world"
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks.count == 2)
#expect(blocks[0].command == "echo hello")
#expect(blocks[0].name == "greeting")
#expect(blocks[0].refresh == 5.0)
#expect(blocks[0].label == nil)
#expect(blocks[1].name == nil)
#expect(blocks[1].refresh == nil)
}
@MainActor
@Test func loadBlocksRejectsInvalidTOML() {
#expect(throws: (any Error).self) {
try PHBlock.load(from: "not = valid = toml = =")
}
}
@MainActor
@Test func computeReturnsCommandStdout() async throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf panini"
""")
let output = await blocks[0].compute()
#expect(output == "panini")
}
@MainActor
@Test func computeRunsInConfigDirectory() async throws {
// Block commands execute with the resolved config root (see `PHPaths`) as
// their working directory, so a block from any set resolves relative paths
// the same way. Guards against the config dir being absent in sandboxed CIs.
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
let blocks = try PHBlock.load(from: """
[[block]]
command = "pwd"
""")
let output = await blocks[0].compute()
// Resolve symlinks on both sides: the config dir is commonly a symlink into
// a dotfiles repo, and `pwd` reports the physical path.
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
#expect(output == expected)
}
// MARK: - PHBlock Refresh
@MainActor
@Test func updateSetsLabel() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf panini"
""")[0]
#expect(block.label == nil)
await block.update()
#expect(block.label == "panini")
}
@MainActor
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf hi"
""")[0]
block.startAutoRefresh()
// Allow the one-shot update task to run.
try await Task.sleep(for: .milliseconds(100))
#expect(block.label == "hi")
block.stopAutoRefresh()
}
@MainActor
@Test func startAutoRefreshRepeatsAtInterval() async throws {
// A counter file lets us observe how many times the command ran.
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_test_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let toml = """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
refresh = 0.05
"""
let block = try PHBlock.load(from: toml)[0]
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(250))
block.stopAutoRefresh()
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
// At ~20Hz over 250ms the command should have run more than once.
#expect(count >= 2)
}
// MARK: - PHEvent
@MainActor
@Test func eventDecodesFromKnownStrings() throws {
let toml = """
[[block]]
command = "echo hi"
events = ["volume", "network", "appearance", "power", "mpd"]
"""
let blocks = try PHBlock.load(from: toml)
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
}
@MainActor
@Test func eventAcceptsArbitraryStringAsCustom() throws {
// Unknown strings no longer fail decoding: they become custom event names
// resolved at runtime against ~/.config/phbar/events/<name>/.
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
events = ["totally_made_up"]
""")
#expect(blocks[0].events == [PHEvent("totally_made_up")])
}
@MainActor
@Test func eventsOptionalWhenOmitted() throws {
let blocks = try PHBlock.load(from: """
[[block]]
command = "echo hi"
""")
#expect(blocks[0].events == nil)
}
@MainActor
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
// No recognizer installed at a clean directory factory yields nil.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
return PHExternalEventSource(recognizer: recognizer)
}
#expect(factory(PHEvent("anything")) == nil)
}
@MainActor
@Test func eventLoaderReturnsNilForMissingEvent() {
// A clean directory has no event folders, so every name fails to load.
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
#expect(recognizer == nil)
}
// MARK: - PHEventRegistry
/// A no-op source that records its lifecycle and can be fired on demand.
@MainActor
private final class FakeEventSource: PHEventSource {
private(set) var startCount = 0
private(set) var stopCount = 0
private var notify: (@MainActor @Sendable () -> Void)?
func start(notify: @escaping @MainActor @Sendable () -> Void) {
startCount += 1
self.notify = notify
}
func stop() {
stopCount += 1
notify = nil
}
func fire() { notify?() }
}
@MainActor
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
#expect(fake.startCount == 0)
#expect(fake.stopCount == 0)
var fired = 0
let s1 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
// A second subscriber must reuse the already-running source.
let s2 = registry.subscribe(PHEvent("volume")) { fired += 1 }
#expect(fake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 2)
// One firing fans out to both subscribers.
fake.fire()
#expect(fired == 2)
// Cancelling one keeps the source alive for the other.
s1.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 0)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
fake.fire()
#expect(fired == 3)
// Cancelling the last subscriber tears the source down.
s2.cancel()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 0)
}
@MainActor
@Test func registryStartsSeparateSourcePerEvent() async throws {
let volumeFake = FakeEventSource()
let networkFake = FakeEventSource()
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
if event == PHEvent("volume") { return volumeFake }
return networkFake
}
let registry = PHEventRegistry(factory: factory)
let v = registry.subscribe(PHEvent("volume")) {}
let n = registry.subscribe(PHEvent("network")) {}
#expect(volumeFake.startCount == 1)
#expect(networkFake.startCount == 1)
#expect(registry.subscriberCount(for: PHEvent("volume")) == 1)
#expect(registry.subscriberCount(for: PHEvent("network")) == 1)
v.cancel()
n.cancel()
}
// MARK: - PHBlock + Events
@MainActor
@Test func blockRefreshesOnEvent() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_evt_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "1")
#expect(fake.startCount == 1)
// Firing the event triggers a second refresh.
fake.fire()
try await Task.sleep(for: .milliseconds(100))
#expect(block.label!.trimmingCharacters(in: .whitespacesAndNewlines) == "2")
// Stopping cancels the subscription and tears the source down.
block.stopAutoRefresh()
try await Task.sleep(for: .milliseconds(20))
#expect(fake.stopCount == 1)
}
@MainActor
@Test func blockCombinesIntervalAndEvents() async throws {
let counter = FileManager.default.temporaryDirectory
.appending(path: "phbar_combo_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: counter) }
let path = counter.path
let fake = FakeEventSource()
let registry = PHEventRegistry(factory: { _ in fake })
let block = try PHBlock.load(from: """
[[block]]
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
refresh = 0.2
events = ["volume"]
""")[0]
block.registry = registry
block.startAutoRefresh()
try await Task.sleep(for: .milliseconds(50))
let afterInitial = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterInitial == 1)
#expect(fake.startCount == 1)
// Both an event and the interval can drive updates independently.
fake.fire()
try await Task.sleep(for: .milliseconds(50))
let afterEvent = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
#expect(afterEvent >= 2)
block.stopAutoRefresh()
}
// MARK: - PHController
@MainActor
@Test func controllerRefreshUpdatesAllBlocks() async throws {
let screen = try #require(NSScreen.main)
let blocks = try PHBlock.load(from: """
[[block]]
command = "printf a"
[[block]]
command = "printf b"
""")
let config = try PHConfig.load()
let theme = try PHTheme.load("voltage")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
controller.refresh()
// Allow the per-block update tasks to run.
try await Task.sleep(for: .milliseconds(100))
#expect(blocks[0].label == "a")
#expect(blocks[1].label == "b")
}
// MARK: - PHThemeDimension
private struct DimensionWrapper: Decodable {
let v: PHThemeWindowDimension
}
@Test func dimensionDecodesPoints() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
}
@Test func dimensionDecodesPercentage() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
}
@Test func dimensionRejectsGarbage() {
let decoder = JSONDecoder()
#expect(throws: (any Error).self) {
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
}
}
@Test func dimensionResolvesAgainstLength() {
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
}
// MARK: - PHThemeAnchor geometry
@Test func anchorPlacesAtTopEdge() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
#expect(origin == CGPoint(x: 0, y: 770))
}
@Test func anchorRespectsTopMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 0, y: 760))
}
@Test func anchorPlacesBottomLeadingCorner() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 12, y: 8))
}
@Test func anchorTrailingCentersVertically() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersWithinContentRect() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
// `.center` centers within the inset rect: a top margin lifts the center,
// it doesn't ignore the margin.
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
// rect: y [0, 700], midY = 350 350 20 = 330
#expect(origin == CGPoint(x: 400, y: 330))
}
// MARK: - Monitor window/blocks resolver
@Test func windowNameFallsBackToGlobal() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
#expect(config.windowName(screenName: "Built-in", screenIndex: 0) == "default")
}
@Test func windowNameMatchesByIndex() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["1": .init(window: "bottom", blocks: nil)])
#expect(config.windowName(screenName: "Whatever", screenIndex: 1) == "bottom")
}
@Test func windowNameMatchesByName() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)])
#expect(config.windowName(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
}
@Test func monitorOverridePrefersNameOverIndex() {
let config = PHConfig(
window: "default", blocks: nil, env: nil,
monitors: ["0": .init(window: "byIndex", blocks: nil), "DELL": .init(window: "byName", blocks: nil)]
)
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "byName")
}
@Test func windowInheritsGlobalWhenOverrideOmitsIt() {
// Override sets blocks only window falls back to the global.
let config = PHConfig(window: "top", blocks: nil, env: nil, monitors: ["1": .init(window: nil, blocks: "laptop")])
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
}
@Test func blocksNameDefaultsToDefault() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "default")
}
@Test func blocksNameUsesGlobalWhenNoOverride() {
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: nil)
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "main")
}
@Test func blocksOverrideBeatsGlobal() {
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: ["1": .init(window: nil, blocks: "alt")])
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "alt")
}
@Test func windowAndBlocksResolveIndependently() {
let config = PHConfig(
window: "top", blocks: "main", env: nil,
monitors: [
"DELL": .init(window: "clock", blocks: nil), // inherits blocks "main"
"1": .init(window: nil, blocks: "laptop"), // inherits window "top"
]
)
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "clock")
#expect(config.blocksName(screenName: "DELL", screenIndex: 0) == "main")
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
}
// MARK: - PHBlock set loading
private func makeBlocksConfigDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@MainActor
@Test func loadSetReadsNamedFile() throws {
let dir = try makeBlocksConfigDir()
defer { try? FileManager.default.removeItem(at: dir) }
let blocksDir = dir.appending(path: "blocks")
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
try Data("""
[[block]]
command = "echo hi"
name = "greeting"
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
let blocks = try PHBlock.load("laptop", in: dir)
#expect(blocks.count == 1)
#expect(blocks[0].name == "greeting")
}
@MainActor
@Test func loadSetThrowsForMissingSet() {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
#expect(throws: (any Error).self) {
try PHBlock.load("nope", in: dir)
}
}
// MARK: - BarWindow.computeFrame
@MainActor
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
let screen = try #require(NSScreen.main)
// A window that omits geometry exercises computeFrame's defaults:
// width 100%, top anchor, no margin a full-screen top bar. Built locally
// (rather than loading the bundled theme) so the test tracks the engine's
// defaults, not the theme file's editorial margins.
let theme = PHTheme(
windows: [PHThemeWindow(name: "default")],
texts: nil,
styles: nil
)
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == screen.frame.width)
#expect(frame.minX == screen.frame.minX)
#expect(frame.minY == screen.frame.maxY - 30)
}
@MainActor
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
let screen = try #require(NSScreen.main)
let theme = PHTheme(
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
texts: nil,
styles: nil
)
let config = PHConfig(window: "abs", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.origin == CGPoint(x: 100, y: 200))
#expect(frame.height == 30)
}
@MainActor
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
// Regression: `width = "100%"` previously ignored leading/trailing margins
// and spanned edge to edge. Margins now inset the content rectangle, so the
// bar spans only between them.
let screen = try #require(NSScreen.main)
let sf = screen.frame
let theme = PHTheme(
windows: [PHThemeWindow(
name: "inset",
width: .percentage(1.0),
anchor: .top,
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
)],
texts: nil,
styles: nil
)
let config = PHConfig(window: "inset", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == sf.width - 40)
#expect(frame.minX == sf.minX + 20)
#expect(frame.maxX == sf.maxX - 20)
}
// MARK: - PHPaths (config directory cascade)
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
#expect(candidates.count == 3)
#expect(candidates[0].path == "/custom/xdg/phbar")
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
#expect(candidates[2].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitRelativeXdg() {
let home = FileManager.default.homeDirectoryForCurrentUser
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
#expect(candidates.count == 2)
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
#expect(candidates[1].path == home.appending(path: ".phbar").path)
}
@Test func pathsCandidatesOmitEmptyXdg() {
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
#expect(candidates.count == 2)
}
@Test func pathsCandidatesOmitXdgWhenUnset() {
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
#expect(candidates.count == 2)
}
@Test func pathsResolvePicksFirstExistingCandidate() throws {
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
// it sorts first in the candidate list.
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
let xdgConfig = xdgRoot.appending(path: "phbar")
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: xdgRoot) }
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
#expect(resolved == xdgConfig)
}
@Test func pathsConfigDirectoryMatchesResolve() {
// The cached static agrees with a fresh resolve against the live process env.
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
}