Compare commits

..

19 Commits

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