Compare commits

...

37 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
tommaso 19a8f09e5a fix an issue with horizontal window margins not respected 2026-07-12 15:23:22 +02:00
tommaso 6de06ac8e1 respect the actual window height 2026-07-12 15:12:13 +02:00
tommaso 24c53eb51d move files around 2026-07-12 15:06:51 +02:00
tommaso a6a7f80cce resolve config directory dynamically following a priority order 2026-07-12 14:49:25 +02:00
tommaso 8315b8a6e3 make bar and blocks monitor scoped 2026-07-12 12:56:35 +02:00
tommaso 34921ca377 move window definition to theme and make it dynamic 2026-07-12 11:58:01 +02:00
tommaso 6e258fdffa cleanup code 2026-07-12 11:39:58 +02:00
tommaso 3da4c8e466 make styles optional in theme 2026-07-12 11:36:27 +02:00
tommaso 70a170f544 move text definition to theme and make it dynamic 2026-07-12 11:34:52 +02:00
tommaso 7c0551c662 make colors and padding optional 2026-07-12 11:16:54 +02:00
tommaso 0edc4ca47a add Makefile 2026-07-12 11:14:49 +02:00
84 changed files with 2327 additions and 870 deletions
+84
View File
@@ -0,0 +1,84 @@
# 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.
# Install directory. Override with: make install BIN_DIR=/opt/homebrew/bin
BIN_DIR ?= /usr/local/bin
BUILD_DIR := .build/release
BINARIES := phbar
# 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. Elevates only the copy when BIN_DIR isn't writable.
install: build
@mkdir -p $(BIN_DIR)
@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: $$($$bin --version 2>/dev/null | head -1)" || echo "$$bin: not found"; \
done
# Uninstall binaries. Elevates only the remove when BIN_DIR isn't writable.
uninstall:
@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)
reinstall: clean install
# Show help
help:
@echo "Available targets:"
@echo " make build - Build in release mode"
@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 $(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"
+2 -5
View File
@@ -32,12 +32,9 @@ let package = Package(
.target(name: "phbarEvents"),
],
exclude: [
"Models/Event/README.md"
"Models/PHEvent/README.md"
],
resources: [
.process("Models/Config/config.toml"),
.process("Models/Theme/theme.toml"),
]
resources: [.copy("config")]
),
.testTarget(
name: "phbarTests",
-38
View File
@@ -1,38 +0,0 @@
import AppKit
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var barController: BarController!
var window: BarWindow!
private var observers: [IPCObserver] = []
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool = false) {
super.init()
self.barController = BarController(config: config, screen: screen, theme: theme, blocks: blocks)
barController.debug = debug
}
func applicationDidFinishLaunching(_ notification: Notification) {
window = BarWindow(controller: barController)
window.orderFront(nil)
barController.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() {
barController.refresh()
}
func applicationWillTerminate(_ notification: Notification) {
barController.stopAutoRefresh()
observers.removeAll()
}
}
-60
View File
@@ -1,60 +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 screen = try targetScreen(monitor: 0)
let theme = try PHTheme.load("voltage")
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
let blocks = try PHBlock.load()
let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
// Monitor
extension phbar.start {
private func targetScreen(monitor: Int) throws -> NSScreen {
let screens = NSScreen.screens
guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") }
if monitor >= 0, monitor < screens.count {
return screens[monitor]
}
// Default: screen containing the mouse cursor
let mouseLocation = NSEvent.mouseLocation
guard
let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) })
?? screens.first
else {
throw CleanExit.message("No monitor founded")
}
return screen
}
}
@@ -0,0 +1,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
}
}
@@ -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"
}
}
nonisolated static var configFile: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml")
}
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> [PHBlock] {
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
return try load(from: Self.configFile)
} else {
throw phbar.Error("Failed to load blocks file")
}
}
/// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(from url: URL) throws -> [PHBlock] {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
throw phbar.Error("the file is empty")
}
return try load(from: contents)
} catch {
throw phbar.Error("Failed to load blocks file", underlyingError: error)
}
}
/// Load theme from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static func load(from contents: String) throws -> [PHBlock] {
do {
let decoder = TOMLDecoder()
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
return wrapper.blocks
} catch {
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
}
}
}
@@ -1,124 +0,0 @@
import Foundation
extension PHBlock {
/// Start keeping the label up to date.
///
/// Three independent triggers drive refreshes, and any combination works:
///
/// 1. **Interval** when `refresh` is a positive number of seconds, the
/// label is recomputed on that interval.
/// 2. **Events** each name in `events` subscribes to a system event source
/// (volume, network, appearance, power). Sources are activated lazily by
/// the registry: a listener runs only while at least one block subscribes
/// to it, so unused events cost nothing.
/// 3. **Manual** `update()`, `PHController.refresh()`, or `phbar refresh`.
///
/// The label is always recomputed once immediately on start, then again on
/// any of the triggers above until `stopAutoRefresh()` is called.
func startAutoRefresh() {
stopAutoRefresh()
// Immediate refresh so the bar isn't blank until the first trigger fires.
scheduleUpdate()
// 1. Periodic refresh.
if let interval = refresh, interval > 0 {
let milliseconds = Int(interval * 1000)
intervalTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break }
await self?.update()
}
}
}
// 2. Event-driven refresh. The registry lazily activates each underlying
// 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()
}
subscriptions.append(subscription)
}
}
/// Cancel the interval task and all event subscriptions.
func stopAutoRefresh() {
intervalTask?.cancel()
intervalTask = nil
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()
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
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
}
}
@@ -1,10 +0,0 @@
enum PHBlockKind: String {
case text, space
}
extension PHBlock {
var kind: PHBlockKind {
if command.starts(with: "_space") { return .space }
return .text
}
}
@@ -1,56 +0,0 @@
import Foundation
import TOML
extension PHConfig {
/// Load configuration from the default path (~/.config/phbar/config.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> PHConfig {
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
path: ".config/phbar/config.toml"
)
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
return try loadFromBundle()
}
}
/// 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.
/// 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 decoder = TOMLDecoder()
let configFile = try decoder.decode(PHConfig.self, from: contents)
return configFile
} catch {
throw phbar.Error("Failed to parse config file", underlyingError: error)
}
}
}
@@ -1,3 +0,0 @@
struct PHConfig: Decodable {
let text: PHConfigText
}
@@ -1,9 +0,0 @@
enum PHConfigTextStyle: String, Decodable {
case normal, italic
}
extension PHConfigText {
var italic: Bool {
style == .italic
}
}
-6
View File
@@ -1,6 +0,0 @@
[text]
font = "Comic Code"
size = 14
weight = "regular"
style = "normal"
offset = -0.5
+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
)
}
}
@@ -4,14 +4,19 @@ 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?
@Published var style: PHThemeStyle = .default
let refresh: Double?
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
}
@@ -34,10 +39,11 @@ 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)
}
}
@@ -0,0 +1,53 @@
import Foundation
extension PHBlock {
/// Start keeping the label up to date.
///
/// Three independent triggers drive refreshes, and any combination works:
///
/// 1. **Interval** when `refresh` is a positive number of seconds, the
/// label is recomputed on that interval.
/// 2. **Events** each name in `events` subscribes to a system event source
/// (volume, network, appearance, power). Sources are activated lazily by
/// the registry: a listener runs only while at least one block subscribes
/// to it, so unused events cost nothing.
/// 3. **Manual** `update()`, `PHController.refresh()`, or `phbar refresh`.
///
/// The label is always recomputed once immediately on start, then again on
/// any of the triggers above until `stopAutoRefresh()` is called.
func startAutoRefresh() {
stopAutoRefresh()
// Immediate refresh so the bar isn't blank until the first trigger fires.
resolve()
// 1. Periodic refresh.
if let interval = refresh, interval > 0 {
let milliseconds = Int(interval * 1000)
intervalTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break }
await self?.resolve()
}
}
}
// 2. Event-driven refresh. The registry lazily activates each underlying
// system listener only while at least one block subscribes to it.
for event in (events ?? []) {
let subscription = registry.subscribe(event) { [weak self] in
self?.resolve()
}
subscriptions.append(subscription)
}
}
/// Cancel the interval task and all event subscriptions.
func stopAutoRefresh() {
intervalTask?.cancel()
intervalTask = nil
subscriptions.forEach { $0.cancel() }
subscriptions.removeAll()
}
}
@@ -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)
}
}
@@ -0,0 +1,11 @@
enum PHBlockKind: String {
case text, space, divider
}
extension PHBlock {
var kind: PHBlockKind {
if name.starts(with: "_space") { return .space }
if name.starts(with: "_divider") { return .divider }
return .text
}
}
+25
View File
@@ -0,0 +1,25 @@
struct PHConfig: Decodable {
let theme: String
let window: String
let layout: String
let monitors: [String: PHConfigMonitorOverride]?
private enum CodingKeys: String, CodingKey {
case theme, window, layout
case monitors = "monitor"
}
var layouts: Set<String> {
var layouts = Set<String>()
layouts.insert(layout)
guard let monitors else { return layouts }
for monitor in monitors.values {
if let layout = monitor.layout {
layouts.insert(layout)
}
}
return layouts
}
}
@@ -0,0 +1,42 @@
import Foundation
import TOML
extension PHConfig {
/// 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.configFile
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
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 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 {
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 = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHConfig.self, from: configFile)
} catch {
throw PHBar.Error("Configuration file not readable", underlyingError: error)
}
}
}
@@ -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
}
}
@@ -5,7 +5,7 @@ import Foundation
/// Modeled as a `String`-backed value so it decodes straight from the TOML
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
/// event sources**: every name resolves at runtime to an external recognizer
/// loaded from `~/.config/phbar/events/<name>/` a `dlopen`'d library whose
/// loaded from `<config>/events/<name>/` a `dlopen`'d library whose
/// root object conforms to `PHEventRecognizer`. To add an event, install a
/// recognizer folder; no host code change is required.
struct PHEvent: Hashable, Sendable {
@@ -3,8 +3,8 @@ import Foundation
import phbarEvents
/// Loads external event recognizers (shared libraries) from the user's config
/// directory: `~/.config/phbar/events/<name>/event.toml` + the referenced
/// library.
/// directory (see `PHPaths`): `<config>/events/<name>/event.toml` + the
/// referenced library.
///
/// Each event lives in its own folder. The manifest names the `.dylib` and,
/// optionally, the factory symbol (default `phbar_event_create`). Libraries are
@@ -31,7 +31,7 @@ final class PHEventLoader {
}
static var defaultDirectory: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/events")
PHPaths.configDirectory.appending(path: "events")
}
/// Returns the recognizer for `name`, loading and caching it on first access.
@@ -2,7 +2,7 @@ import Foundation
import TOML
/// The manifest describing an external event recognizer, read from
/// `~/.config/phbar/events/<name>/event.toml`.
/// `<config>/events/<name>/event.toml`.
///
/// ```toml
/// library = "event.dylib" # required: shared library file name
@@ -85,7 +85,7 @@ final class PHEventRegistry {
/// Resolves an event to its source. phbar ships no compiled sources: every
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
/// recognizer from `~/.config/phbar/events/<name>/`. Returns `nil` if no
/// recognizer from `<config>/events/<name>/`. Returns `nil` if no
/// recognizer is installed.
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
+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)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
/// Resolves phbar's configuration directory.
///
/// Searches these locations in priority order and returns the first that
/// exists on disk:
///
/// 1. `$XDG_CONFIG_HOME/phbar` only when `XDG_CONFIG_HOME` is set to an
/// absolute path (a relative/empty value is ignored, per the XDG spec).
/// 2. `~/.config/phbar`
/// 3. `~/.phbar`
///
/// If none exist yet (fresh install), the highest-priority candidate is
/// returned so loaders and block commands have a stable root to resolve into.
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);
/// defaults to the current process environment.
static func configDirectoryCandidates(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [URL] {
let home = FileManager.default.homeDirectoryForCurrentUser
var candidates: [URL] = []
if let xdg = environment["XDG_CONFIG_HOME"] {
let trimmed = xdg.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("/") {
candidates.append(URL(fileURLWithPath: trimmed).appending(path: "phbar"))
}
}
candidates.append(home.appending(path: ".config/phbar"))
candidates.append(home.appending(path: ".phbar"))
return candidates
}
/// First existing candidate, or the highest-priority one if none exist.
///
/// - Parameter environment: Override the environment lookup (used by tests);
/// defaults to the current process environment.
static func resolveConfigDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> URL {
let candidates = configDirectoryCandidates(environment: environment)
for candidate in candidates {
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
}
return candidates[0]
}
}
+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)) }
)
}
}
+11
View File
@@ -0,0 +1,11 @@
struct PHTheme: Decodable {
let windows: [PHThemeWindow]?
let texts: [PHThemeText]?
let styles: [PHThemeStyle]?
private enum CodingKeys: String, CodingKey {
case windows = "window"
case texts = "text"
case styles = "style"
}
}
@@ -0,0 +1,43 @@
import Foundation
import TOML
extension PHTheme {
/// 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 {
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 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) else {
throw PHBar.Error("content is not UTF8 encoded.")
}
let decoder = TOMLDecoder()
let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
return try decoder.decode(PHTheme.self, from: themeFile)
} catch {
let theme = url.lastPathComponent
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
}
}
}
@@ -1,15 +1,17 @@
struct PHThemeStyle: Decodable {
let name: String
let foreground: PHThemeColor
let background: PHThemeColor
let padding: PHThemePadding
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(leading: 10, trailing: 10)
padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
corner: nil
)
}()
}
@@ -1,9 +1,9 @@
import SwiftUI
struct PHThemeColor: Decodable, ShapeStyle {
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
}
@@ -0,0 +1,13 @@
struct PHThemeStylePadding: Decodable {
let top: Double?
let bottom: Double?
let leading: Double?
let trailing: Double?
private enum CodingKeys: String, CodingKey {
case top
case bottom
case leading = "left"
case trailing = "right"
}
}
@@ -1,22 +1,35 @@
import AppKit
import SwiftUI
struct PHConfigText: Decodable {
let fontFamily: String
struct PHThemeText: Decodable {
let name: String
var fontFamily: String
let size: Double
let weight: PHConfigTextWeight
let style: PHConfigTextStyle
let weight: PHThemeTextWeight
let style: PHThemeTextStyle
let offset: Double?
private enum CodingKeys: String, CodingKey {
case name
case fontFamily = "font"
case size, weight, style, offset
}
static let `default`: Self = {
PHThemeText(
name: "_default",
fontFamily: "monospace",
size: 14.0,
weight: .regular,
style: .normal,
offset: nil
)
}()
}
// Design
extension PHConfigText {
extension PHThemeText {
var design: SwiftUI.Font.Design? {
switch fontFamily {
case "sans":
@@ -46,7 +59,7 @@ extension PHConfigText {
// Font
extension PHConfigText {
extension PHThemeText {
/// Construct an `NSFont` from the typeface settings.
///
/// Falls back to the system font if parsing fails.
@@ -0,0 +1,9 @@
enum PHThemeTextStyle: String, Decodable {
case normal, italic
}
extension PHThemeText {
var italic: Bool {
style == .italic
}
}
@@ -1,7 +1,7 @@
import AppKit
import SwiftUI
enum PHConfigTextWeight: String, Decodable {
enum PHThemeTextWeight: String, Decodable {
case thin
case ultraLight = "ultralight"
case light
@@ -0,0 +1,33 @@
import Foundation
struct PHThemeWindow: Decodable {
let name: String
var hasShadow: Bool? = false
var blur: Double? = 0
/// Window height in points.
var height: Double? = 30
/// Window width: a point value or a percentage of the screen width.
var width: PHThemeWindowDimension? = .percentage(1.0)
/// Semantic placement within the screen.
var anchor: PHThemeWindowAnchor? = .top
/// Insets from the anchored edges.
var margin: PHThemeWindowMargin? = .init()
/// Absolute origin. Overrides `anchor` and `margin` when set.
var origin: PHThemeWindowPoint?
private enum CodingKeys: String, CodingKey {
case name
case hasShadow = "shadow"
case blur
case height, width, anchor, margin, origin
}
static let `default`: Self = {
PHThemeWindow(
name: "_default",
hasShadow: false,
blur: 0.0
)
}()
}
@@ -0,0 +1,60 @@
import CoreGraphics
import Foundation
/// Where a window is placed within its screen.
///
/// Single-edge anchors (`top`, `bottom`, `leading`, `trailing`) pin to that
/// edge and center along the opposite axis. Corner anchors pin to two edges.
/// `center` centers the window on both axes.
enum PHThemeWindowAnchor: String, Decodable {
case top, bottom, leading, trailing
case topLeading = "top-left"
case topTrailing = "top-right"
case bottomLeading = "bottom-left"
case bottomTrailing = "bottom-right"
case center
}
extension PHThemeWindowAnchor {
/// Compute the top-left origin (in screen coordinates) of a window of `size`
/// placed against this anchor within `rect`.
///
/// `rect` is the content rectangle the window is placed within typically the
/// screen inset by the window's margin (see `PHThemeWindowMargin.inset(of:)`).
/// Single-edge anchors pin to that edge and center along the opposite axis;
/// corner anchors pin to two edges; `.center` centers on both axes.
func origin(in rect: CGRect, size: CGSize) -> CGPoint {
// Default: centered on both axes within the rect.
var point = CGPoint(
x: rect.midX - size.width / 2,
y: rect.midY - size.height / 2
)
switch self {
case .top:
point.y = rect.maxY - size.height
case .bottom:
point.y = rect.minY
case .leading:
point.x = rect.minX
case .trailing:
point.x = rect.maxX - size.width
case .topLeading:
point.x = rect.minX
point.y = rect.maxY - size.height
case .topTrailing:
point.x = rect.maxX - size.width
point.y = rect.maxY - size.height
case .bottomLeading:
point.x = rect.minX
point.y = rect.minY
case .bottomTrailing:
point.x = rect.maxX - size.width
point.y = rect.minY
case .center:
break
}
return point
}
}
@@ -0,0 +1,48 @@
import CoreGraphics
import Foundation
/// A length expressed either in points or as a percentage of the available
/// space (e.g. the screen width).
///
/// Decoded from either a number (`width = 400`) or a percentage string
/// (`width = "100%"`).
enum PHThemeWindowDimension: Decodable, Equatable {
case points(Double)
case percentage(Double)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// TOML encodes integers and floats distinctly, so accept both.
if let double = try? container.decode(Double.self) {
self = .points(double)
return
}
if let int = try? container.decode(Int.self) {
self = .points(Double(int))
return
}
if let string = try? container.decode(String.self) {
let trimmed = string.trimmingCharacters(in: .whitespaces)
if trimmed.hasSuffix("%"), let value = Double(trimmed.dropLast()) {
self = .percentage(value / 100.0)
return
}
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: #"Expected a number or a percentage string like "100%""#
)
}
/// Resolve the dimension against a total length.
func resolve(against length: CGFloat) -> CGFloat {
switch self {
case .points(let value):
return CGFloat(value)
case .percentage(let ratio):
return length * CGFloat(ratio)
}
}
}
@@ -0,0 +1,50 @@
import CoreGraphics
import Foundation
/// Insets (in points) from the screen edges, defining the content rectangle a
/// window is sized and placed within.
///
/// Margins compose with `width`/`height`: a `width = "100%"` window spans the
/// space *between* the leading/trailing margins rather than the full screen.
/// Unspecified edges are left untouched (treated as 0).
struct PHThemeWindowMargin: Decodable, Equatable {
let top: Double?
let bottom: Double?
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,
leading: Double? = nil,
trailing: Double? = nil
) {
self.top = top
self.bottom = bottom
self.leading = leading
self.trailing = trailing
}
/// Returns `rect` inset by the specified edges; unspecified edges are left
/// untouched (treated as 0). Uses AppKit's coordinate system (origin at the
/// bottom-left, y increasing upward): `top` reduces the max-Y edge.
func inset(of rect: CGRect) -> CGRect {
let l = leading ?? 0
let r = trailing ?? 0
let t = top ?? 0
let b = bottom ?? 0
return CGRect(
x: rect.minX + l,
y: rect.minY + b,
width: rect.width - l - r,
height: rect.height - t - b
)
}
}
@@ -0,0 +1,10 @@
import Foundation
/// An absolute screen-space origin.
///
/// When set on a window, it overrides anchor/margin placement entirely the
/// window appears at exactly these coordinates.
struct PHThemeWindowPoint: Decodable, Equatable {
let x: Double
let y: Double
}
@@ -1,59 +0,0 @@
import Foundation
import TOML
extension PHTheme {
/// Load theme from the config directory (~/.config/phbar/themes/).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load(_ theme: String?) throws -> PHTheme {
guard let theme else { return try loadFromBundle() }
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
path: ".config/phbar/themes/\(theme).toml"
)
if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url)
} else {
return try loadFromBundle()
}
}
/// 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.
/// 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")
}
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)
} catch {
throw phbar.Error("Failed to parse theme file", underlyingError: error)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
struct PHTheme: Decodable {
let window: PHThemeWindow
let styles: [PHThemeStyle]
private enum CodingKeys: String, CodingKey {
case window
case styles = "style"
}
}
@@ -1,10 +0,0 @@
struct PHThemePadding: Decodable {
let leading: Double
let trailing: Double
var total: Double { leading + trailing }
static var zero: PHThemePadding {
Self.init(leading: 0, trailing: 0)
}
}
@@ -1,9 +0,0 @@
struct PHThemeWindow: Decodable {
let hasShadow: Bool
let blur: Double
private enum CodingKeys: String, CodingKey {
case hasShadow = "shadow"
case blur
}
}
-25
View File
@@ -1,25 +0,0 @@
# phbar theme file ~ Voltage
#
# Place at ~/.config/phbar/themes/voltage.toml
[window]
shadow = false
blur = 0.0
[[style]]
name = "default"
foreground = { color = "#aed3f3" }
background = { color = "#010408", alpha = 0.825 }
padding = { leading = 0.0, trailing = 0.0 }
[[style]]
name = "floating"
foreground = { color = "#aed3f3" }
background = { color = "#000000", alpha = 0 }
padding = { leading = 0.0, trailing = 0.0 }
[[style]]
name = "tinted"
foreground = { color = "#aed3f3" }
background = { color = "#0f304a" }
padding = { leading = 12.0, trailing = 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?
+6 -6
View File
@@ -1,17 +1,17 @@
import AppKit
final class BackgroundView: NSView {
var hasShadow: Bool {
var hasShadow: Bool? = false {
didSet { configureShadow() }
}
var blur: CGFloat {
var blur: CGFloat? = 0 {
didSet { configureBlur() }
}
init(controller: BarController) {
self.hasShadow = controller.theme.window.hasShadow
self.blur = controller.theme.window.blur
if let hasShadow = controller.window.hasShadow { self.hasShadow = hasShadow }
if let blur = controller.window.blur { self.blur = blur }
super.init(frame: .zero)
setup()
}
@@ -36,12 +36,12 @@ final class BackgroundView: NSView {
}
private func configureShadow() {
guard let window else { return }
guard let window, let hasShadow else { return }
window.hasShadow = hasShadow
}
private func configureBlur() {
guard let window else { return }
guard let window, let blur else { return }
// Window must be non-opaque for the desktop/other windows behind it
// to be visible at all, let alone blurred.
window.isOpaque = false
+44 -13
View File
@@ -5,13 +5,7 @@ final class BarController: ObservableObject {
let config: PHConfig
let screen: NSScreen
let theme: PHTheme
var debug: Bool = false {
didSet {
for block in blocks {
block.debug = debug
}
}
}
let window: PHThemeWindow
@Published var blocks: [PHBlock]
var arrangedBlocks: [PHBlock] {
@@ -22,24 +16,61 @@ final class BarController: ObservableObject {
blocks.filter { $0.centered == true }
}
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock]) {
init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
self.config = config
self.screen = screen
self.theme = theme
self.blocks = blocks
self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = layout.blocks
for block in blocks {
block.text = text(for: block)
block.style = style(for: block)
block.debug = debug
}
}
}
// Style
// Theming
extension BarController {
/// Resolve the window definition for a given screen, applying any
/// 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 {
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
/// built-in default.
static func resolve(window name: String, from theme: PHTheme) -> PHThemeWindow {
guard let window = theme.windows?.first(where: { $0.name == name }) else {
guard let defaultWindow = theme.windows?.first(where: { $0.name == "default" }) else {
return PHThemeWindow.default
}
return defaultWindow
}
return window
}
func text(for block: PHBlock) -> PHThemeText {
guard let text = theme.texts?.first(where: { $0.name == block.textName }) else {
guard let defaultText = theme.texts?.first(where: { $0.name == "default" }) else {
return PHThemeText.default
}
return defaultText
}
return text
}
func style(for block: PHBlock) -> PHThemeStyle {
guard let style = theme.styles.first(where: { $0.name == block.styleName }) else {
guard let defaultStyle = theme.styles.first(where: { $0.name == "default" }) else {
guard let style = theme.styles?.first(where: { $0.name == block.styleName }) else {
guard let defaultStyle = theme.styles?.first(where: { $0.name == "default" }) else {
return PHThemeStyle.default
}
return defaultStyle
@@ -69,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()
}
}
}
+9 -7
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,16 +26,16 @@ 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)
}
}
}
}
.font(bar.config.text.font)
.italic(bar.config.text.italic)
.frame(height: 30)
.frame(height: bar.window.height ?? 30)
.ignoresSafeArea()
.environmentObject(bar)
}
+40 -5
View File
@@ -46,13 +46,48 @@ final class BarWindow: NSPanel {
}
extension BarWindow {
/// Calculate window frame and position.
/// 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 {
let screenFrame = controller.screen.frame
let height: CGFloat = 30
let x = screenFrame.minX
let y = screenFrame.maxY - height
let themeWindow = controller.window
return NSRect(x: x, y: y, width: screenFrame.width, height: height)
// Absolute origin bypasses the anchor/margin system entirely: width
// resolves against the full screen and the window sits at the exact
// coordinates given.
if let origin = themeWindow.origin {
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: screenFrame.width),
height: CGFloat(themeWindow.height ?? 30)
)
return NSRect(
x: CGFloat(origin.x),
y: CGFloat(origin.y),
width: size.width,
height: size.height
)
}
// Margins define a content rectangle within the screen: the window is
// sized against it (so `width = "100%"` spans only between the margins,
// not the full screen) and placed by the anchor inside it.
let margin = themeWindow.margin ?? .init()
let contentRect = margin.inset(of: screenFrame)
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: contentRect.width),
height: CGFloat(themeWindow.height ?? 30)
)
let anchor = themeWindow.anchor ?? .top
return NSRect(
origin: anchor.origin(in: contentRect, size: size),
size: size
)
}
}
+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,17 +1,17 @@
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)
}
var body: some View {
Rectangle()
.fill(block.style.background)
.fill(block.style.background?.uiColor ?? .clear)
.frame(width: width)
}
}
-49
View File
@@ -1,49 +0,0 @@
import SwiftUI
struct TextBlockView: View {
@EnvironmentObject private var bar: BarController
@ObservedObject var block: PHBlock
@State var hovering = false
private var adjustOffset: CGAffineTransform {
guard let offset = bar.config.text.offset else { return .init() }
return .init(translationX: 0, y: offset)
}
var body: some View {
if let label = block.label, !label.isEmpty {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 0)
.fill(block.style.background)
Group {
Text(label)
.foregroundStyle(block.style.foreground)
.transformEffect(adjustOffset)
.border(bar.debug ? .blue : .clear)
}
.padding(.leading, block.style.padding.leading)
.padding(.trailing, block.style.padding.trailing)
}
.fixedSize(horizontal: true, vertical: false)
.border(bar.debug ? .red : .clear)
.onHover { hovering = $0 }
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown,
.otherMouseDown,
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent)
}
return event
}
}
} else {
EmptyView()
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import SwiftUI
struct TextView: View {
@EnvironmentObject private var bar: BarController
@ObservedObject var block: PHBlock
@State var hovering = false
private var adjustOffset: CGAffineTransform {
guard let offset = block.text.offset else { return .init() }
return .init(translationX: 0, y: offset)
}
var body: some View {
if let label = block.label, !label.isEmpty {
ZStack(alignment: .center) {
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)
.font(block.text.font)
.italic(block.text.italic)
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
.transformEffect(adjustOffset)
.border(block.debug ? .blue : .clear)
}
.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)
.onHover { hovering = $0 }
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown,
.otherMouseDown,
.scrollWheel,
]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.resolve(with: gestureEvent)
}
return event
}
}
} else {
EmptyView()
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - window: a [[window]] definition from the theme file
# - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default"
window = "default"
layout = "default"
# Optional: override the window and/or layout per monitor.
# - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value.
#
# [monitor."1"]
# window = "bottom"
# layout = "laptop"
#
# [monitor."DELL U2723QE"]
# window = "clock"
# layout = "external"
+31
View File
@@ -0,0 +1,31 @@
[[block]]
name = "version"
style = "elevated"
[[block]]
name = "_space 14"
[[block]]
name = "attribution"
command = 'echo "by Panini House"'
text = "italic"
[[block]]
name = "_space"
[[block]]
name = "gesture"
[[block]]
name = "_space 14"
[[block]]
name = "clock"
refresh = 1
style = "elevated"
[[block]]
name = "greetings"
command = 'echo "􀟱 Hi $USER", welcome to phbar!'
text = "italic"
centered = true
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "􀐬 $(date "+%a %d, %H:%M:%S")"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
text="Click me"
case $GESTURE in
"left_mouse_down") text="That's left mouse button!" ;;
"right_mouse_down") text="That's right mouse button!" ;;
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
"scroll_wheel") text="Yes, you can even scroll!" ;;
esac
echo "􀭆 $text"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "phbar v$(phbar --version)"
+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 }
+3 -3
View File
@@ -4,9 +4,9 @@ import Foundation
///
/// phbar ships a set of built-in recognizers (volume, network, appearance,
/// power, mpd) compiled into the host. External recognizers are loaded at
/// runtime as shared libraries from `~/.config/phbar/events/<name>/` and conform
/// to this same protocol, so the host drives built-in and external recognizers
/// through one shape.
/// runtime as shared libraries from the events directory
/// (`<config>/events/<name>/`) and conform to this same protocol, so the host
/// drives built-in and external recognizers through one shape.
///
/// Conform an `NSObject` subclass and export a zero-argument factory via
/// `@_cdecl(PHEventCreateSymbol)`:
+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)
}
@@ -1,258 +1,9 @@
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 == "")
#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")
}
// MARK: - PHBlock Refresh
@MainActor
@Test func updateSetsLabel() async throws {
let block = try PHBlock.load(from: """
[[block]]
command = "printf panini"
""")[0]
#expect(block.label == "")
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
// MARK: - Fake event source
/// A no-op source that records its lifecycle and can be fired on demand.
@MainActor
@@ -274,6 +25,70 @@ private final class FakeEventSource: PHEventSource {
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()
@@ -334,7 +149,7 @@ private final class FakeEventSource: PHEventSource {
n.cancel()
}
// MARK: - PHBlock + Events
// MARK: - Block + events
@MainActor
@Test func blockRefreshesOnEvent() async throws {
@@ -349,6 +164,7 @@ private final class FakeEventSource: PHEventSource {
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
@@ -385,6 +201,7 @@ private final class FakeEventSource: PHEventSource {
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]
@@ -406,30 +223,3 @@ private final class FakeEventSource: PHEventSource {
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)
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")
}
+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)
}