Compare commits

...

11 Commits

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