Compare commits
33 Commits
3da4c8e466
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f70ea7f0f5
|
|||
|
4a74cf04c8
|
|||
|
3e9557586b
|
|||
|
f479499309
|
|||
|
30be4b2901
|
|||
|
ed01251ca5
|
|||
|
1867a4c0a6
|
|||
|
a894449d15
|
|||
|
a49f78cbb0
|
|||
|
1a2eddcd5d
|
|||
|
909d890837
|
|||
|
87070dd62f
|
|||
|
39457d8faf
|
|||
|
a963a19bf3
|
|||
|
aa241148ef
|
|||
|
07a05e5470
|
|||
|
94c83d74a4
|
|||
|
33c4cbe26e
|
|||
|
3c5222d800
|
|||
|
27fad1d67c
|
|||
|
9a7710d675
|
|||
|
f5e1ba1104
|
|||
|
77717c9654
|
|||
|
d4d1b7bec4
|
|||
|
b872659796
|
|||
|
0ff1664974
|
|||
|
19a8f09e5a
|
|||
|
6de06ac8e1
|
|||
|
24c53eb51d
|
|||
|
a6a7f80cce
|
|||
|
8315b8a6e3
|
|||
|
34921ca377
|
|||
|
6e258fdffa
|
@@ -1,46 +1,68 @@
|
|||||||
# 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 \
|
||||||
|
$$CP $(BUILD_DIR)/$$bin $(BIN_DIR)/; \
|
||||||
done
|
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 \
|
||||||
|
$$RM $(BIN_DIR)/$$bin; \
|
||||||
done
|
done
|
||||||
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
|
@echo "✅ Uninstalled $(BINARIES) from $(BIN_DIR)"
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
+2
-5
@@ -32,12 +32,9 @@ let package = Package(
|
|||||||
.target(name: "phbarEvents"),
|
.target(name: "phbarEvents"),
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"Models/Event/README.md"
|
"Models/PHEvent/README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [.copy("config")]
|
||||||
.process("Models/Config/config.toml"),
|
|
||||||
.process("Models/Theme/theme.toml"),
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "phbarTests",
|
name: "phbarTests",
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
||||||
var barController: BarController!
|
|
||||||
var window: BarWindow!
|
|
||||||
private var observers: [IPCObserver] = []
|
|
||||||
|
|
||||||
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool = false) {
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
self.barController = BarController(config: config, screen: screen, theme: theme, blocks: blocks)
|
|
||||||
barController.debug = debug
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
||||||
window = BarWindow(controller: barController)
|
|
||||||
window.orderFront(nil)
|
|
||||||
barController.startAutoRefresh()
|
|
||||||
|
|
||||||
// Listen for refresh notifications from `phbar refresh`.
|
|
||||||
let token = IPC.observe(.refresh) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh() {
|
|
||||||
barController.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
|
||||||
barController.stopAutoRefresh()
|
|
||||||
observers.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import ArgumentParser
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension phbar {
|
|
||||||
struct start: ParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(
|
|
||||||
commandName: "start",
|
|
||||||
abstract: "Start the status bar."
|
|
||||||
)
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Display visual guides to help align elements.")
|
|
||||||
var debug: Bool = false
|
|
||||||
|
|
||||||
mutating func run() throws {
|
|
||||||
let config = try PHConfig.load()
|
|
||||||
let screen = try targetScreen(monitor: 0)
|
|
||||||
let theme = try PHTheme.load("voltage")
|
|
||||||
|
|
||||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
|
||||||
dispatchPrecondition(condition: .onQueue(.main))
|
|
||||||
|
|
||||||
try MainActor.assumeIsolated {
|
|
||||||
let blocks = try PHBlock.load()
|
|
||||||
|
|
||||||
let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
|
|
||||||
let app = NSApplication.shared
|
|
||||||
app.setActivationPolicy(.accessory)
|
|
||||||
app.delegate = delegate
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
throw ExitCode.success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor
|
|
||||||
|
|
||||||
extension phbar.start {
|
|
||||||
private func targetScreen(monitor: Int) throws -> NSScreen {
|
|
||||||
let screens = NSScreen.screens
|
|
||||||
guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") }
|
|
||||||
|
|
||||||
if monitor >= 0, monitor < screens.count {
|
|
||||||
return screens[monitor]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: screen containing the mouse cursor
|
|
||||||
let mouseLocation = NSEvent.mouseLocation
|
|
||||||
guard
|
|
||||||
let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) })
|
|
||||||
?? screens.first
|
|
||||||
else {
|
|
||||||
throw CleanExit.message("No monitor founded")
|
|
||||||
}
|
|
||||||
|
|
||||||
return screen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBar {
|
||||||
|
struct Install: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "install",
|
||||||
|
abstract: "Start the status bar.",
|
||||||
|
discussion: """
|
||||||
|
Launch the bar as a non-stopping background process.
|
||||||
|
This command is meant to be used for both testing and production.
|
||||||
|
|
||||||
|
When testing, you can provide the `--debug` flag to get visual
|
||||||
|
guidance that helps you configure the blocks and a verbose output from
|
||||||
|
the shell scripts to debug the behaviour.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
private var source: URL? {
|
||||||
|
Bundle.module.url(forResource: "config", withExtension: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var destination: URL {
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
|
||||||
|
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let standardConfigDirectory = homeDirectory.appending(path: ".config")
|
||||||
|
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
|
||||||
|
in: .whitespaces)
|
||||||
|
var candidateDirectory: URL
|
||||||
|
|
||||||
|
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
|
||||||
|
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
|
||||||
|
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
|
||||||
|
candidateDirectory = standardConfigDirectory
|
||||||
|
} else {
|
||||||
|
candidateDirectory = homeDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateDirectory.appending(path: "phbar")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() throws {
|
||||||
|
do {
|
||||||
|
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
|
||||||
|
try FileManager.default.copyItem(at: source, to: destination)
|
||||||
|
try makeExecutable()
|
||||||
|
} catch {
|
||||||
|
throw PHBar.Error("Failed to create config directory", underlyingError: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CleanExit.message("Config directory created at \(destination.relativePath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeExecutable() throws {
|
||||||
|
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
|
||||||
|
let scripts = try FileManager.default.contentsOfDirectory(
|
||||||
|
atPath: scriptsDirectory.relativePath
|
||||||
|
)
|
||||||
|
|
||||||
|
for script in scripts {
|
||||||
|
let path = scriptsDirectory.appending(path: script)
|
||||||
|
var attributes: [FileAttributeKey: Any] = [:]
|
||||||
|
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
|
||||||
|
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import ArgumentParser
|
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."
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import AppKit
|
||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBar {
|
||||||
|
struct Start: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "start",
|
||||||
|
abstract: "Start the status bar.",
|
||||||
|
discussion: """
|
||||||
|
Launch the bar as a non-stopping background process.
|
||||||
|
This command is meant to be used for both testing and production.
|
||||||
|
|
||||||
|
When testing, you can provide the `--debug` flag to get visual
|
||||||
|
guidance that helps you configure the blocks and a verbose output from
|
||||||
|
the shell scripts to debug the behaviour.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Flag(name: .long, help: "Display visual guides and print verbose output.")
|
||||||
|
var debug: Bool = false
|
||||||
|
|
||||||
|
mutating func run() throws {
|
||||||
|
let config = try PHConfig.load()
|
||||||
|
let theme = try PHTheme.load(config.theme)
|
||||||
|
let layouts = try PHLayout.load(["default", "mini"])
|
||||||
|
|
||||||
|
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
|
||||||
|
try MainActor.assumeIsolated {
|
||||||
|
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
|
||||||
|
|
||||||
|
let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug)
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
throw ExitCode.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
extension String {
|
||||||
|
/// Expand `$VAR` and `${VAR}` references against `variables`.
|
||||||
|
///
|
||||||
|
/// Unresolved references are left intact rather than blanked, so a typo
|
||||||
|
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
|
||||||
|
/// vanishing.
|
||||||
|
func expanded(from environment: [String: String]) -> String {
|
||||||
|
var result = self
|
||||||
|
|
||||||
|
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
|
||||||
|
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
|
||||||
|
environment[String(match.1)] ?? String(match.0)
|
||||||
|
}
|
||||||
|
// `$VAR`
|
||||||
|
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
|
||||||
|
environment[String(match.1)] ?? String(match.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import TOML
|
|
||||||
|
|
||||||
extension PHBlock {
|
|
||||||
private struct Wrapper: Decodable {
|
|
||||||
let blocks: [PHBlock]
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case blocks = "block"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static var configFile: URL {
|
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
|
|
||||||
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
|
||||||
static func load() throws -> [PHBlock] {
|
|
||||||
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
|
|
||||||
return try load(from: Self.configFile)
|
|
||||||
} else {
|
|
||||||
throw phbar.Error("Failed to load blocks file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load theme from a file at URL.
|
|
||||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
|
||||||
static func load(from url: URL) throws -> [PHBlock] {
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
|
||||||
throw phbar.Error("the file is empty")
|
|
||||||
}
|
|
||||||
return try load(from: contents)
|
|
||||||
} catch {
|
|
||||||
throw phbar.Error("Failed to load blocks file", underlyingError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load theme from a TOML string.
|
|
||||||
/// If the content fails to parse, the execution is interrupted.
|
|
||||||
static func load(from contents: String) throws -> [PHBlock] {
|
|
||||||
do {
|
|
||||||
let decoder = TOMLDecoder()
|
|
||||||
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
|
|
||||||
return wrapper.blocks
|
|
||||||
} catch {
|
|
||||||
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension PHBlock {
|
|
||||||
/// Start keeping the label up to date.
|
|
||||||
///
|
|
||||||
/// Three independent triggers drive refreshes, and any combination works:
|
|
||||||
///
|
|
||||||
/// 1. **Interval** — when `refresh` is a positive number of seconds, the
|
|
||||||
/// label is recomputed on that interval.
|
|
||||||
/// 2. **Events** — each name in `events` subscribes to a system event source
|
|
||||||
/// (volume, network, appearance, power). Sources are activated lazily by
|
|
||||||
/// the registry: a listener runs only while at least one block subscribes
|
|
||||||
/// to it, so unused events cost nothing.
|
|
||||||
/// 3. **Manual** — `update()`, `PHController.refresh()`, or `phbar refresh`.
|
|
||||||
///
|
|
||||||
/// The label is always recomputed once immediately on start, then again on
|
|
||||||
/// any of the triggers above until `stopAutoRefresh()` is called.
|
|
||||||
func startAutoRefresh() {
|
|
||||||
stopAutoRefresh()
|
|
||||||
|
|
||||||
// Immediate refresh so the bar isn't blank until the first trigger fires.
|
|
||||||
scheduleUpdate()
|
|
||||||
|
|
||||||
// 1. Periodic refresh.
|
|
||||||
if let interval = refresh, interval > 0 {
|
|
||||||
let milliseconds = Int(interval * 1000)
|
|
||||||
intervalTask = Task { [weak self] in
|
|
||||||
while !Task.isCancelled {
|
|
||||||
try? await Task.sleep(for: .milliseconds(milliseconds))
|
|
||||||
if Task.isCancelled { break }
|
|
||||||
await self?.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Event-driven refresh. The registry lazily activates each underlying
|
|
||||||
// system listener only while at least one block subscribes to it.
|
|
||||||
for event in (events ?? []) {
|
|
||||||
let subscription = registry.subscribe(event) { [weak self] in
|
|
||||||
self?.scheduleUpdate()
|
|
||||||
}
|
|
||||||
subscriptions.append(subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel the interval task and all event subscriptions.
|
|
||||||
func stopAutoRefresh() {
|
|
||||||
intervalTask?.cancel()
|
|
||||||
intervalTask = nil
|
|
||||||
subscriptions.forEach { $0.cancel() }
|
|
||||||
subscriptions.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute the label from `command`. Used by the interval loop, event
|
|
||||||
/// handlers, and manual updates.
|
|
||||||
func update() async {
|
|
||||||
label = await compute()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request an update on the main actor, coalescing rapid bursts into a single
|
|
||||||
/// recomputation.
|
|
||||||
private func scheduleUpdate() {
|
|
||||||
guard !updateScheduled else { return }
|
|
||||||
updateScheduled = true
|
|
||||||
Task { [weak self] in
|
|
||||||
defer { self?.updateScheduled = false }
|
|
||||||
await self?.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGesture(_ event: PHGestureEvent) {
|
|
||||||
Task { label = await compute(with: event) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the provided command and returns its stdout.
|
|
||||||
///
|
|
||||||
/// Runs off the main actor so it can be awaited safely from SwiftUI views
|
|
||||||
/// without blocking the UI.
|
|
||||||
///
|
|
||||||
/// - Returns: A non-optional String to use as the block label.
|
|
||||||
func compute(with gestureEvent: PHGestureEvent? = nil) async -> String? {
|
|
||||||
guard kind == .text else { return nil }
|
|
||||||
|
|
||||||
let command = command
|
|
||||||
|
|
||||||
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
|
|
||||||
let process = Process()
|
|
||||||
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
|
|
||||||
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
||||||
process.arguments = ["-c", command]
|
|
||||||
|
|
||||||
// Inherit the parent's environment (includes $PATH, etc.)
|
|
||||||
process.environment = ProcessInfo.processInfo.environment
|
|
||||||
if let gestureEvent {
|
|
||||||
let type = gestureEvent.type.rawValue
|
|
||||||
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
|
|
||||||
|
|
||||||
if let data = gestureEvent.data {
|
|
||||||
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture stdout via a pipe (otherwise standardOutput is always nil).
|
|
||||||
let pipe = Pipe()
|
|
||||||
process.standardOutput = pipe
|
|
||||||
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let string = String(data: data, encoding: .utf8)
|
|
||||||
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
|
|
||||||
return lines
|
|
||||||
} catch {
|
|
||||||
// Silently ignore failures
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}.value
|
|
||||||
|
|
||||||
guard let lines else { return nil }
|
|
||||||
if self.debug { for line in lines { print(line) } }
|
|
||||||
return lines.last?.description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
enum PHBlockKind: String {
|
|
||||||
case text, space
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PHBlock {
|
|
||||||
var kind: PHBlockKind {
|
|
||||||
if command.starts(with: "_space") { return .space }
|
|
||||||
return .text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import TOML
|
|
||||||
|
|
||||||
extension PHConfig {
|
|
||||||
/// Load configuration from the default path (~/.config/phbar/config.toml).
|
|
||||||
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
|
||||||
static func load() throws -> PHConfig {
|
|
||||||
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
|
||||||
path: ".config/phbar/config.toml"
|
|
||||||
)
|
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
|
||||||
return try load(from: url)
|
|
||||||
} else {
|
|
||||||
return try loadFromBundle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the bundled configuration.
|
|
||||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
|
||||||
static private func loadFromBundle() throws -> PHConfig {
|
|
||||||
guard
|
|
||||||
let defaultConfig = Bundle.module.url(
|
|
||||||
forResource: "config",
|
|
||||||
withExtension: "toml"
|
|
||||||
)?.resolvingSymlinksInPath()
|
|
||||||
else {
|
|
||||||
throw phbar.Error("Failed to load config file")
|
|
||||||
}
|
|
||||||
return try load(from: defaultConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode configuration from a file at URL.
|
|
||||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
|
||||||
static private func load(from url: URL) throws -> PHConfig {
|
|
||||||
guard let data = try? Data(contentsOf: url),
|
|
||||||
let contents = String(data: data, encoding: .utf8)
|
|
||||||
else {
|
|
||||||
throw phbar.Error("Failed to load config file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return try load(from: contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode configuration from a TOML string.
|
|
||||||
/// If the content fails to parse, the execution is interrupted.
|
|
||||||
static private func load(from contents: String) throws -> PHConfig {
|
|
||||||
do {
|
|
||||||
let decoder = TOMLDecoder()
|
|
||||||
let configFile = try decoder.decode(PHConfig.self, from: contents)
|
|
||||||
return configFile
|
|
||||||
} catch {
|
|
||||||
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
struct PHConfig: Decodable {
|
|
||||||
let env: [String: String]?
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
height = 30
|
|
||||||
|
|
||||||
[window]
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Builds a `BarController` for a screen by resolving its layout
|
||||||
|
/// and window from the config.
|
||||||
|
@MainActor
|
||||||
|
struct PHBarFactory {
|
||||||
|
let config: PHConfig
|
||||||
|
let theme: PHTheme
|
||||||
|
let layouts: [String: PHLayout]
|
||||||
|
let debug: Bool
|
||||||
|
|
||||||
|
/// Resolve layout for `screen` and build its controller.
|
||||||
|
///
|
||||||
|
/// - Parameter screen: The screen to build a bar for. Its localized name
|
||||||
|
/// and index in `NSScreen.screens` select any per-monitor config override.
|
||||||
|
/// - Throws: `PHBlock.load` error for the resolved names.
|
||||||
|
func make(for screen: NSScreen) throws -> BarController {
|
||||||
|
let name = screen.localizedName
|
||||||
|
let index = NSScreen.screens.firstIndex(of: screen)
|
||||||
|
guard let layout = layouts[config.layout(screenName: name, screenIndex: index)] else {
|
||||||
|
throw PHBar.Error("Layout `\(name)` not found.")
|
||||||
|
}
|
||||||
|
return BarController(
|
||||||
|
config: config,
|
||||||
|
screen: screen,
|
||||||
|
theme: theme,
|
||||||
|
layout: layout,
|
||||||
|
debug: debug
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@ import Foundation
|
|||||||
final class PHBlock: ObservableObject, Decodable, Identifiable {
|
final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|
||||||
let command: String
|
let name: String
|
||||||
let name: String?
|
|
||||||
let textName: String?
|
let textName: String?
|
||||||
@Published var text: PHThemeText = .default
|
@Published var text: PHThemeText = .default
|
||||||
let styleName: String?
|
let styleName: String?
|
||||||
@@ -14,6 +13,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
let centered: Bool?
|
let centered: Bool?
|
||||||
var debug: Bool = false
|
var debug: Bool = false
|
||||||
|
|
||||||
|
nonisolated var command: String {
|
||||||
|
PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
|
||||||
|
}
|
||||||
|
|
||||||
var visible: Bool {
|
var visible: Bool {
|
||||||
label != nil && !label!.isEmpty
|
label != nil && !label!.isEmpty
|
||||||
}
|
}
|
||||||
@@ -36,10 +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 command, name
|
case name
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBlock {
|
||||||
|
/// Start keeping the label up to date.
|
||||||
|
///
|
||||||
|
/// Three independent triggers drive refreshes, and any combination works:
|
||||||
|
///
|
||||||
|
/// 1. **Interval** — when `refresh` is a positive number of seconds, the
|
||||||
|
/// label is recomputed on that interval.
|
||||||
|
/// 2. **Events** — each name in `events` subscribes to a system event source
|
||||||
|
/// (volume, network, appearance, power). Sources are activated lazily by
|
||||||
|
/// the registry: a listener runs only while at least one block subscribes
|
||||||
|
/// to it, so unused events cost nothing.
|
||||||
|
/// 3. **Manual** — `update()`, `PHController.refresh()`, or `phbar refresh`.
|
||||||
|
///
|
||||||
|
/// The label is always recomputed once immediately on start, then again on
|
||||||
|
/// any of the triggers above until `stopAutoRefresh()` is called.
|
||||||
|
func startAutoRefresh() {
|
||||||
|
stopAutoRefresh()
|
||||||
|
|
||||||
|
// Immediate refresh so the bar isn't blank until the first trigger fires.
|
||||||
|
resolve()
|
||||||
|
|
||||||
|
// 1. Periodic refresh.
|
||||||
|
if let interval = refresh, interval > 0 {
|
||||||
|
let milliseconds = Int(interval * 1000)
|
||||||
|
intervalTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .milliseconds(milliseconds))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await self?.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Event-driven refresh. The registry lazily activates each underlying
|
||||||
|
// system listener only while at least one block subscribes to it.
|
||||||
|
for event in (events ?? []) {
|
||||||
|
let subscription = registry.subscribe(event) { [weak self] in
|
||||||
|
self?.resolve()
|
||||||
|
}
|
||||||
|
subscriptions.append(subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the interval task and all event subscriptions.
|
||||||
|
func stopAutoRefresh() {
|
||||||
|
intervalTask?.cancel()
|
||||||
|
intervalTask = nil
|
||||||
|
subscriptions.forEach { $0.cancel() }
|
||||||
|
subscriptions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBlock {
|
||||||
|
/// Resolve the label by executing `command`.
|
||||||
|
/// Used by the interval loop, event handlers, and manual updates.
|
||||||
|
func resolve(merging environment: [String: String]? = nil) {
|
||||||
|
guard canResolve() else { return }
|
||||||
|
|
||||||
|
guard !resolveScheduled else { return }
|
||||||
|
resolveScheduled = true
|
||||||
|
|
||||||
|
Task(name: command, priority: .userInitiated) { [weak self] in
|
||||||
|
defer { self?.resolveScheduled = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
self?.label = try await self?.resolve(merging: environment)
|
||||||
|
} catch {
|
||||||
|
self?.printStandardError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the label by executing `command`.
|
||||||
|
/// Used by the interval loop, event handlers, and manual updates.
|
||||||
|
func resolve(merging environment: [String: String]? = nil) async {
|
||||||
|
guard canResolve() else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
label = try await resolve(merging: environment)
|
||||||
|
} catch {
|
||||||
|
printStandardError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(merging environment: [String: String]?) async throws -> String? {
|
||||||
|
let customEnvironment = environment
|
||||||
|
|
||||||
|
var environment = ProcessInfo.processInfo.environment
|
||||||
|
if let customEnvironment {
|
||||||
|
environment.merge(customEnvironment) { (_, new) in new }
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await resolve(with: environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(with environment: [String: String]) async throws -> String? {
|
||||||
|
do {
|
||||||
|
let (stdout, stderr) = try await resolve(command, with: environment)
|
||||||
|
printStandardError(stderr)
|
||||||
|
printStandardOutput(stdout)
|
||||||
|
guard let lines = stdout?.split(whereSeparator: \.isNewline),
|
||||||
|
let last = lines.last
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return String(last)
|
||||||
|
} catch {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canResolve() -> Bool {
|
||||||
|
guard kind == .text else { return false }
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ command: String, with environment: [String: String]) async throws -> (
|
||||||
|
stdout: String?,
|
||||||
|
stderr: String?
|
||||||
|
) {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let process = Process()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
process.currentDirectoryURL = PHPaths.configDirectory
|
||||||
|
process.environment = environment
|
||||||
|
process.arguments = ["-c", command]
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
|
let stdout = String(data: stdoutData, encoding: .utf8)
|
||||||
|
let stderr = String(data: stderrData, encoding: .utf8)
|
||||||
|
|
||||||
|
continuation.resume(returning: (stdout, stderr))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a message to standard output.
|
||||||
|
/// Silenced unless in debug mode.
|
||||||
|
///
|
||||||
|
/// - Parameter message: the content of the message
|
||||||
|
private func printStandardOutput(_ message: String?) {
|
||||||
|
guard debug, let data = message?.data(using: .utf8) else { return }
|
||||||
|
FileHandle.standardOutput.write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a message to standard error.
|
||||||
|
/// Silenced unless in debug mode.
|
||||||
|
///
|
||||||
|
/// - Parameter message: the content of the message
|
||||||
|
private func printStandardError(_ message: String?) {
|
||||||
|
guard debug, let data = message?.data(using: .utf8) else { return }
|
||||||
|
FileHandle.standardError.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
enum PHBlockKind: String {
|
||||||
|
case text, space, divider
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHBlock {
|
||||||
|
var kind: PHBlockKind {
|
||||||
|
if name.starts(with: "_space") { return .space }
|
||||||
|
if name.starts(with: "_divider") { return .divider }
|
||||||
|
return .text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
struct PHConfig: Decodable {
|
||||||
|
let theme: String
|
||||||
|
let window: String
|
||||||
|
let layout: String
|
||||||
|
let monitors: [String: PHConfigMonitorOverride]?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case theme, window, layout
|
||||||
|
case monitors = "monitor"
|
||||||
|
}
|
||||||
|
|
||||||
|
var layouts: Set<String> {
|
||||||
|
var layouts = Set<String>()
|
||||||
|
layouts.insert(layout)
|
||||||
|
|
||||||
|
guard let monitors else { return layouts }
|
||||||
|
for monitor in monitors.values {
|
||||||
|
if let layout = monitor.layout {
|
||||||
|
layouts.insert(layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layouts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import TOML
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
/// Load the configuration from the resolved config directory (see `PHPaths`).
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static func load() throws -> PHConfig {
|
||||||
|
let url = PHPaths.configFile
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||||
|
return try load(from: url)
|
||||||
|
} else {
|
||||||
|
let config = url.lastPathComponent
|
||||||
|
throw PHBar.Error(
|
||||||
|
"""
|
||||||
|
Configuration file not found.
|
||||||
|
Make sure to have a file named `\(config)` inside the
|
||||||
|
directory: `\(PHPaths.configDirectory.relativePath)`
|
||||||
|
|
||||||
|
Tip: If you've never used phbar before, run the `phbar install` command
|
||||||
|
to automatically generate the required configuration files.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the configuration from the file at URL.
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static private func load(from url: URL) throws -> PHConfig {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let contents = String(data: data, encoding: .utf8) else {
|
||||||
|
throw PHBar.Error("content is not UTF8 encoded.")
|
||||||
|
}
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
let configFile = contents.expanded(from: ProcessInfo.processInfo.environment)
|
||||||
|
return try decoder.decode(PHConfig.self, from: configFile)
|
||||||
|
} catch {
|
||||||
|
throw PHBar.Error("Configuration file not readable", underlyingError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A per-monitor entry from the config's `[monitor]` table.
|
||||||
|
///
|
||||||
|
/// Either field is optional; an unset field inherits the matching global
|
||||||
|
/// (`window` or `layout`) so a monitor can override just one of them.
|
||||||
|
struct PHConfigMonitorOverride: Decodable {
|
||||||
|
let window: String?
|
||||||
|
let layout: String?
|
||||||
|
|
||||||
|
init(window: String? = nil, layout: String? = nil) {
|
||||||
|
self.window = window
|
||||||
|
self.layout = layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
/// The per-monitor override matching the given screen identity, if any.
|
||||||
|
///
|
||||||
|
/// Precedence: monitor name → monitor index.
|
||||||
|
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
|
||||||
|
guard let monitors else { return nil }
|
||||||
|
if let byName = monitors[screenName] { return byName }
|
||||||
|
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window name for a screen: the override's `window` if set,
|
||||||
|
/// otherwise the global `window`.
|
||||||
|
func window(screenName: String, screenIndex: Int?) -> String {
|
||||||
|
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout name for a screen: the override's `layout` if set,
|
||||||
|
/// otherwise the global `layout`.
|
||||||
|
func layout(screenName: String, screenIndex: Int?) -> String {
|
||||||
|
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import Foundation
|
|||||||
/// Modeled as a `String`-backed value so it decodes straight from the TOML
|
/// Modeled as a `String`-backed value so it decodes straight from the TOML
|
||||||
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
|
/// config (e.g. `events = ["volume", "network"]`). phbar ships **no compiled
|
||||||
/// event sources**: every name resolves at runtime to an external recognizer
|
/// event sources**: every name resolves at runtime to an external recognizer
|
||||||
/// loaded from `~/.config/phbar/events/<name>/` — a `dlopen`'d library whose
|
/// loaded from `<config>/events/<name>/` — a `dlopen`'d library whose
|
||||||
/// root object conforms to `PHEventRecognizer`. To add an event, install a
|
/// root object conforms to `PHEventRecognizer`. To add an event, install a
|
||||||
/// recognizer folder; no host code change is required.
|
/// recognizer folder; no host code change is required.
|
||||||
struct PHEvent: Hashable, Sendable {
|
struct PHEvent: Hashable, Sendable {
|
||||||
+3
-3
@@ -3,8 +3,8 @@ import Foundation
|
|||||||
import phbarEvents
|
import phbarEvents
|
||||||
|
|
||||||
/// Loads external event recognizers (shared libraries) from the user's config
|
/// Loads external event recognizers (shared libraries) from the user's config
|
||||||
/// directory: `~/.config/phbar/events/<name>/event.toml` + the referenced
|
/// directory (see `PHPaths`): `<config>/events/<name>/event.toml` + the
|
||||||
/// library.
|
/// referenced library.
|
||||||
///
|
///
|
||||||
/// Each event lives in its own folder. The manifest names the `.dylib` and,
|
/// Each event lives in its own folder. The manifest names the `.dylib` and,
|
||||||
/// optionally, the factory symbol (default `phbar_event_create`). Libraries are
|
/// optionally, the factory symbol (default `phbar_event_create`). Libraries are
|
||||||
@@ -31,7 +31,7 @@ final class PHEventLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static var defaultDirectory: URL {
|
static var defaultDirectory: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/events")
|
PHPaths.configDirectory.appending(path: "events")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the recognizer for `name`, loading and caching it on first access.
|
/// Returns the recognizer for `name`, loading and caching it on first access.
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import TOML
|
import TOML
|
||||||
|
|
||||||
/// The manifest describing an external event recognizer, read from
|
/// The manifest describing an external event recognizer, read from
|
||||||
/// `~/.config/phbar/events/<name>/event.toml`.
|
/// `<config>/events/<name>/event.toml`.
|
||||||
///
|
///
|
||||||
/// ```toml
|
/// ```toml
|
||||||
/// library = "event.dylib" # required: shared library file name
|
/// library = "event.dylib" # required: shared library file name
|
||||||
+1
-1
@@ -85,7 +85,7 @@ final class PHEventRegistry {
|
|||||||
|
|
||||||
/// Resolves an event to its source. phbar ships no compiled sources: every
|
/// Resolves an event to its source. phbar ships no compiled sources: every
|
||||||
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
|
/// name is handed to `PHEventLoader`, which `dlopen`s a user-supplied
|
||||||
/// recognizer from `~/.config/phbar/events/<name>/`. Returns `nil` if no
|
/// recognizer from `<config>/events/<name>/`. Returns `nil` if no
|
||||||
/// recognizer is installed.
|
/// recognizer is installed.
|
||||||
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
|
static func defaultFactory(_ event: PHEvent) -> (any PHEventSource)? {
|
||||||
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
|
guard let recognizer = PHEventLoader.shared.recognizer(for: event.rawValue) else { return nil }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Resolves phbar's configuration directory.
|
||||||
|
///
|
||||||
|
/// Searches these locations in priority order and returns the first that
|
||||||
|
/// exists on disk:
|
||||||
|
///
|
||||||
|
/// 1. `$XDG_CONFIG_HOME/phbar` — only when `XDG_CONFIG_HOME` is set to an
|
||||||
|
/// absolute path (a relative/empty value is ignored, per the XDG spec).
|
||||||
|
/// 2. `~/.config/phbar`
|
||||||
|
/// 3. `~/.phbar`
|
||||||
|
///
|
||||||
|
/// If none exist yet (fresh install), the highest-priority candidate is
|
||||||
|
/// returned so loaders and block commands have a stable root to resolve into.
|
||||||
|
enum PHPaths {
|
||||||
|
/// The resolved configuration directory (computed once, cached).
|
||||||
|
static let configDirectory: URL = resolveConfigDirectory()
|
||||||
|
|
||||||
|
/// The resolved config file (computed once, cached).
|
||||||
|
static let configFile: URL = {
|
||||||
|
configDirectory.appending(path: "config.toml")
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The resolved themes directory (computed once, cached).
|
||||||
|
static let themesDirectory: URL = {
|
||||||
|
configDirectory.appending(path: "themes")
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The resolved layouts directory (computed once, cached).
|
||||||
|
static let layoutsDirectory: URL = {
|
||||||
|
configDirectory.appending(path: "layouts")
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Ordered candidate directories, priority high → low.
|
||||||
|
///
|
||||||
|
/// - Parameter environment: Override the environment lookup (used by tests);
|
||||||
|
/// defaults to the current process environment.
|
||||||
|
static func configDirectoryCandidates(
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
|
) -> [URL] {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
var candidates: [URL] = []
|
||||||
|
|
||||||
|
if let xdg = environment["XDG_CONFIG_HOME"] {
|
||||||
|
let trimmed = xdg.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasPrefix("/") {
|
||||||
|
candidates.append(URL(fileURLWithPath: trimmed).appending(path: "phbar"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.append(home.appending(path: ".config/phbar"))
|
||||||
|
candidates.append(home.appending(path: ".phbar"))
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First existing candidate, or the highest-priority one if none exist.
|
||||||
|
///
|
||||||
|
/// - Parameter environment: Override the environment lookup (used by tests);
|
||||||
|
/// defaults to the current process environment.
|
||||||
|
static func resolveConfigDirectory(
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
|
) -> URL {
|
||||||
|
let candidates = configDirectoryCandidates(environment: environment)
|
||||||
|
for candidate in candidates {
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
struct PHTheme: Decodable {
|
struct PHTheme: Decodable {
|
||||||
let window: PHThemeWindow
|
let windows: [PHThemeWindow]?
|
||||||
let texts: [PHThemeText]?
|
let texts: [PHThemeText]?
|
||||||
let styles: [PHThemeStyle]?
|
let styles: [PHThemeStyle]?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case window
|
case windows = "window"
|
||||||
case texts = "text"
|
case texts = "text"
|
||||||
case styles = "style"
|
case styles = "style"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import TOML
|
||||||
|
|
||||||
|
extension PHTheme {
|
||||||
|
/// Load a theme from the resolved config directory (see `PHPaths`).
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static func load(_ theme: String) throws -> PHTheme {
|
||||||
|
let url = PHPaths.themesDirectory.appending(path: "\(theme).toml")
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||||
|
return try load(from: url)
|
||||||
|
} else {
|
||||||
|
let theme = url.lastPathComponent
|
||||||
|
throw PHBar.Error(
|
||||||
|
"""
|
||||||
|
Theme file not found.
|
||||||
|
Make sure to have a file named `\(theme)` inside the
|
||||||
|
directory: `\(PHPaths.themesDirectory.relativePath)`
|
||||||
|
|
||||||
|
Tip: If you've never used phbar before, run the `phbar install` command
|
||||||
|
to automatically generate the required configuration files.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a theme from the file at URL.
|
||||||
|
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||||
|
static private func load(from url: URL) throws -> PHTheme {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let contents = String(data: data, encoding: .utf8) else {
|
||||||
|
throw PHBar.Error("content is not UTF8 encoded.")
|
||||||
|
}
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
let themeFile = contents.expanded(from: ProcessInfo.processInfo.environment)
|
||||||
|
return try decoder.decode(PHTheme.self, from: themeFile)
|
||||||
|
} catch {
|
||||||
|
let theme = url.lastPathComponent
|
||||||
|
throw PHBar.Error("Theme file `\(theme)` not readable", underlyingError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -1,15 +1,17 @@
|
|||||||
struct PHThemeStyle: Decodable {
|
struct PHThemeStyle: Decodable {
|
||||||
let name: String
|
let name: String
|
||||||
let foreground: PHThemeColor?
|
let foreground: PHThemeStyleColor?
|
||||||
let background: PHThemeColor?
|
let background: PHThemeStyleColor?
|
||||||
let padding: PHThemePadding?
|
let padding: PHThemeStylePadding?
|
||||||
|
let corner: PHThemeStyleCorner?
|
||||||
|
|
||||||
static let `default`: Self = {
|
static let `default`: Self = {
|
||||||
PHThemeStyle(
|
PHThemeStyle(
|
||||||
name: "_default",
|
name: "_default",
|
||||||
foreground: .init(color: "#000000", alpha: nil),
|
foreground: .init(color: "#000000", alpha: nil),
|
||||||
background: .init(color: "#ffffff", alpha: nil),
|
background: .init(color: "#ffffff", alpha: nil),
|
||||||
padding: .init(trailing: 12, leading: 12)
|
padding: .init(top: 0, bottom: 0, leading: 12, trailing: 12),
|
||||||
|
corner: nil
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PHThemeColor: Decodable, ShapeStyle {
|
struct PHThemeStyleColor: Decodable, ShapeStyle {
|
||||||
typealias Resolved = SwiftUI.Color
|
typealias Resolved = SwiftUI.Color
|
||||||
|
|
||||||
let color: String
|
var color: String
|
||||||
let alpha: Double?
|
let alpha: Double?
|
||||||
|
|
||||||
var uiColor: SwiftUI.Color {
|
var uiColor: SwiftUI.Color {
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PHThemeStyleCorner: Decodable {
|
||||||
|
let radius: Double
|
||||||
|
private let _style: PHThemeStyleCornerStyle?
|
||||||
|
|
||||||
|
var style: SwiftUI.RoundedCornerStyle {
|
||||||
|
switch _style {
|
||||||
|
case .circular:
|
||||||
|
return .circular
|
||||||
|
case .continuous:
|
||||||
|
return .continuous
|
||||||
|
default:
|
||||||
|
return .circular
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case radius
|
||||||
|
case _style = "style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PHThemeStyleCornerStyle: String, Decodable {
|
||||||
|
case circular, continuous
|
||||||
|
}
|
||||||
+7
-3
@@ -1,9 +1,13 @@
|
|||||||
struct PHThemePadding: Decodable {
|
struct PHThemeStylePadding: Decodable {
|
||||||
let trailing: Double?
|
let top: Double?
|
||||||
|
let bottom: Double?
|
||||||
let leading: Double?
|
let leading: Double?
|
||||||
|
let trailing: Double?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case trailing = "right"
|
case top
|
||||||
|
case bottom
|
||||||
case leading = "left"
|
case leading = "left"
|
||||||
|
case trailing = "right"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct PHThemeText: Decodable {
|
struct PHThemeText: Decodable {
|
||||||
let name: String
|
let name: String
|
||||||
let fontFamily: String
|
var fontFamily: String
|
||||||
let size: Double
|
let size: Double
|
||||||
let weight: PHThemeTextWeight
|
let weight: PHThemeTextWeight
|
||||||
let style: PHThemeTextStyle
|
let style: PHThemeTextStyle
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PHThemeWindow: Decodable {
|
||||||
|
let name: String
|
||||||
|
var hasShadow: Bool? = false
|
||||||
|
var blur: Double? = 0
|
||||||
|
|
||||||
|
/// Window height in points.
|
||||||
|
var height: Double? = 30
|
||||||
|
/// Window width: a point value or a percentage of the screen width.
|
||||||
|
var width: PHThemeWindowDimension? = .percentage(1.0)
|
||||||
|
/// Semantic placement within the screen.
|
||||||
|
var anchor: PHThemeWindowAnchor? = .top
|
||||||
|
/// Insets from the anchored edges.
|
||||||
|
var margin: PHThemeWindowMargin? = .init()
|
||||||
|
/// Absolute origin. Overrides `anchor` and `margin` when set.
|
||||||
|
var origin: PHThemeWindowPoint?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case hasShadow = "shadow"
|
||||||
|
case blur
|
||||||
|
case height, width, anchor, margin, origin
|
||||||
|
}
|
||||||
|
|
||||||
|
static let `default`: Self = {
|
||||||
|
PHThemeWindow(
|
||||||
|
name: "_default",
|
||||||
|
hasShadow: false,
|
||||||
|
blur: 0.0
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Where a window is placed within its screen.
|
||||||
|
///
|
||||||
|
/// Single-edge anchors (`top`, `bottom`, `leading`, `trailing`) pin to that
|
||||||
|
/// edge and center along the opposite axis. Corner anchors pin to two edges.
|
||||||
|
/// `center` centers the window on both axes.
|
||||||
|
enum PHThemeWindowAnchor: String, Decodable {
|
||||||
|
case top, bottom, leading, trailing
|
||||||
|
case topLeading = "top-left"
|
||||||
|
case topTrailing = "top-right"
|
||||||
|
case bottomLeading = "bottom-left"
|
||||||
|
case bottomTrailing = "bottom-right"
|
||||||
|
case center
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PHThemeWindowAnchor {
|
||||||
|
/// Compute the top-left origin (in screen coordinates) of a window of `size`
|
||||||
|
/// placed against this anchor within `rect`.
|
||||||
|
///
|
||||||
|
/// `rect` is the content rectangle the window is placed within — typically the
|
||||||
|
/// screen inset by the window's margin (see `PHThemeWindowMargin.inset(of:)`).
|
||||||
|
/// Single-edge anchors pin to that edge and center along the opposite axis;
|
||||||
|
/// corner anchors pin to two edges; `.center` centers on both axes.
|
||||||
|
func origin(in rect: CGRect, size: CGSize) -> CGPoint {
|
||||||
|
// Default: centered on both axes within the rect.
|
||||||
|
var point = CGPoint(
|
||||||
|
x: rect.midX - size.width / 2,
|
||||||
|
y: rect.midY - size.height / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .top:
|
||||||
|
point.y = rect.maxY - size.height
|
||||||
|
case .bottom:
|
||||||
|
point.y = rect.minY
|
||||||
|
case .leading:
|
||||||
|
point.x = rect.minX
|
||||||
|
case .trailing:
|
||||||
|
point.x = rect.maxX - size.width
|
||||||
|
case .topLeading:
|
||||||
|
point.x = rect.minX
|
||||||
|
point.y = rect.maxY - size.height
|
||||||
|
case .topTrailing:
|
||||||
|
point.x = rect.maxX - size.width
|
||||||
|
point.y = rect.maxY - size.height
|
||||||
|
case .bottomLeading:
|
||||||
|
point.x = rect.minX
|
||||||
|
point.y = rect.minY
|
||||||
|
case .bottomTrailing:
|
||||||
|
point.x = rect.maxX - size.width
|
||||||
|
point.y = rect.minY
|
||||||
|
case .center:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A length expressed either in points or as a percentage of the available
|
||||||
|
/// space (e.g. the screen width).
|
||||||
|
///
|
||||||
|
/// Decoded from either a number (`width = 400`) or a percentage string
|
||||||
|
/// (`width = "100%"`).
|
||||||
|
enum PHThemeWindowDimension: Decodable, Equatable {
|
||||||
|
case points(Double)
|
||||||
|
case percentage(Double)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
|
||||||
|
// TOML encodes integers and floats distinctly, so accept both.
|
||||||
|
if let double = try? container.decode(Double.self) {
|
||||||
|
self = .points(double)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let int = try? container.decode(Int.self) {
|
||||||
|
self = .points(Double(int))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let string = try? container.decode(String.self) {
|
||||||
|
let trimmed = string.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasSuffix("%"), let value = Double(trimmed.dropLast()) {
|
||||||
|
self = .percentage(value / 100.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
in: container,
|
||||||
|
debugDescription: #"Expected a number or a percentage string like "100%""#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the dimension against a total length.
|
||||||
|
func resolve(against length: CGFloat) -> CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .points(let value):
|
||||||
|
return CGFloat(value)
|
||||||
|
case .percentage(let ratio):
|
||||||
|
return length * CGFloat(ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Insets (in points) from the screen edges, defining the content rectangle a
|
||||||
|
/// window is sized and placed within.
|
||||||
|
///
|
||||||
|
/// Margins compose with `width`/`height`: a `width = "100%"` window spans the
|
||||||
|
/// space *between* the leading/trailing margins rather than the full screen.
|
||||||
|
/// Unspecified edges are left untouched (treated as 0).
|
||||||
|
struct PHThemeWindowMargin: Decodable, Equatable {
|
||||||
|
let top: Double?
|
||||||
|
let bottom: Double?
|
||||||
|
let leading: Double?
|
||||||
|
let trailing: Double?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case top
|
||||||
|
case bottom
|
||||||
|
case leading = "left"
|
||||||
|
case trailing = "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
top: Double? = nil,
|
||||||
|
bottom: Double? = nil,
|
||||||
|
leading: Double? = nil,
|
||||||
|
trailing: Double? = nil
|
||||||
|
) {
|
||||||
|
self.top = top
|
||||||
|
self.bottom = bottom
|
||||||
|
self.leading = leading
|
||||||
|
self.trailing = trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `rect` inset by the specified edges; unspecified edges are left
|
||||||
|
/// untouched (treated as 0). Uses AppKit's coordinate system (origin at the
|
||||||
|
/// bottom-left, y increasing upward): `top` reduces the max-Y edge.
|
||||||
|
func inset(of rect: CGRect) -> CGRect {
|
||||||
|
let l = leading ?? 0
|
||||||
|
let r = trailing ?? 0
|
||||||
|
let t = top ?? 0
|
||||||
|
let b = bottom ?? 0
|
||||||
|
return CGRect(
|
||||||
|
x: rect.minX + l,
|
||||||
|
y: rect.minY + b,
|
||||||
|
width: rect.width - l - r,
|
||||||
|
height: rect.height - t - b
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// An absolute screen-space origin.
|
||||||
|
///
|
||||||
|
/// When set on a window, it overrides anchor/margin placement entirely — the
|
||||||
|
/// window appears at exactly these coordinates.
|
||||||
|
struct PHThemeWindowPoint: Decodable, Equatable {
|
||||||
|
let x: Double
|
||||||
|
let y: Double
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import TOML
|
|
||||||
|
|
||||||
extension PHTheme {
|
|
||||||
/// Load theme from the config directory (~/.config/phbar/themes/).
|
|
||||||
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
|
||||||
static func load(_ theme: String?) throws -> PHTheme {
|
|
||||||
guard let theme else { return try loadFromBundle() }
|
|
||||||
|
|
||||||
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
|
||||||
path: ".config/phbar/themes/\(theme).toml"
|
|
||||||
)
|
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
|
||||||
return try load(from: url)
|
|
||||||
} else {
|
|
||||||
return try loadFromBundle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the bundled theme.
|
|
||||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
|
||||||
static private func loadFromBundle() throws -> PHTheme {
|
|
||||||
guard
|
|
||||||
let defaultConfig = Bundle.module.url(
|
|
||||||
forResource: "theme",
|
|
||||||
withExtension: "toml"
|
|
||||||
)?.resolvingSymlinksInPath()
|
|
||||||
else {
|
|
||||||
throw phbar.Error("Failed to load theme file")
|
|
||||||
}
|
|
||||||
return try load(from: defaultConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load theme from a file at URL.
|
|
||||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
|
||||||
static private func load(from url: URL) throws -> PHTheme {
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
|
||||||
throw phbar.Error("the file is empty")
|
|
||||||
}
|
|
||||||
return try load(from: contents)
|
|
||||||
} catch {
|
|
||||||
throw phbar.Error("Failed to load theme file", underlyingError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load theme from a TOML string.
|
|
||||||
/// If the content fails to parse, the execution is interrupted.
|
|
||||||
static private func load(from contents: String) throws -> PHTheme {
|
|
||||||
do {
|
|
||||||
let decoder = TOMLDecoder()
|
|
||||||
return try decoder.decode(PHTheme.self, from: contents)
|
|
||||||
} catch {
|
|
||||||
throw phbar.Error("Failed to parse theme file", underlyingError: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
struct PHThemeWindow: Decodable {
|
|
||||||
let hasShadow: Bool
|
|
||||||
let blur: Double
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case hasShadow = "shadow"
|
|
||||||
case blur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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?
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class BackgroundView: NSView {
|
final class BackgroundView: NSView {
|
||||||
var hasShadow: Bool {
|
var hasShadow: Bool? = false {
|
||||||
didSet { configureShadow() }
|
didSet { configureShadow() }
|
||||||
}
|
}
|
||||||
|
|
||||||
var blur: CGFloat {
|
var blur: CGFloat? = 0 {
|
||||||
didSet { configureBlur() }
|
didSet { configureBlur() }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(controller: BarController) {
|
init(controller: BarController) {
|
||||||
self.hasShadow = controller.theme.window.hasShadow
|
if let hasShadow = controller.window.hasShadow { self.hasShadow = hasShadow }
|
||||||
self.blur = controller.theme.window.blur
|
if let blur = controller.window.blur { self.blur = blur }
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
setup()
|
setup()
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,12 @@ final class BackgroundView: NSView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureShadow() {
|
private func configureShadow() {
|
||||||
guard let window else { return }
|
guard let window, let hasShadow else { return }
|
||||||
window.hasShadow = hasShadow
|
window.hasShadow = hasShadow
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureBlur() {
|
private func configureBlur() {
|
||||||
guard let window else { return }
|
guard let window, let blur else { return }
|
||||||
// Window must be non-opaque for the desktop/other windows behind it
|
// Window must be non-opaque for the desktop/other windows behind it
|
||||||
// to be visible at all, let alone blurred.
|
// to be visible at all, let alone blurred.
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
|
|||||||
@@ -5,13 +5,7 @@ final class BarController: ObservableObject {
|
|||||||
let config: PHConfig
|
let config: PHConfig
|
||||||
let screen: NSScreen
|
let screen: NSScreen
|
||||||
let theme: PHTheme
|
let theme: PHTheme
|
||||||
var debug: Bool = false {
|
let window: PHThemeWindow
|
||||||
didSet {
|
|
||||||
for block in blocks {
|
|
||||||
block.debug = debug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published var blocks: [PHBlock]
|
@Published var blocks: [PHBlock]
|
||||||
|
|
||||||
var arrangedBlocks: [PHBlock] {
|
var arrangedBlocks: [PHBlock] {
|
||||||
@@ -22,15 +16,17 @@ final class BarController: ObservableObject {
|
|||||||
blocks.filter { $0.centered == true }
|
blocks.filter { $0.centered == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock]) {
|
init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, debug: Bool) {
|
||||||
self.config = config
|
self.config = config
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.blocks = blocks
|
self.window = Self.window(for: config, screen: screen, from: theme)
|
||||||
|
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.debug = debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +34,30 @@ final class BarController: ObservableObject {
|
|||||||
// Theming
|
// Theming
|
||||||
|
|
||||||
extension BarController {
|
extension BarController {
|
||||||
|
/// Resolve the window definition for a given screen, applying any
|
||||||
|
/// per-monitor override from the config. Selection lives on `PHConfig`
|
||||||
|
/// (see `window(screenName:screenIndex:)`); this looks the name up in the
|
||||||
|
/// theme, falling back to "default".
|
||||||
|
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
|
||||||
|
let name = config.window(
|
||||||
|
screenName: screen.localizedName,
|
||||||
|
screenIndex: NSScreen.screens.firstIndex(of: screen)
|
||||||
|
)
|
||||||
|
return resolve(window: name, from: theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a window definition by name, falling back to "default" then the
|
||||||
|
/// built-in default.
|
||||||
|
static func resolve(window name: String, from theme: PHTheme) -> PHThemeWindow {
|
||||||
|
guard let window = theme.windows?.first(where: { $0.name == name }) else {
|
||||||
|
guard let defaultWindow = theme.windows?.first(where: { $0.name == "default" }) else {
|
||||||
|
return PHThemeWindow.default
|
||||||
|
}
|
||||||
|
return defaultWindow
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
func text(for block: PHBlock) -> PHThemeText {
|
func text(for block: PHBlock) -> PHThemeText {
|
||||||
guard let text = theme.texts?.first(where: { $0.name == block.textName }) else {
|
guard let text = theme.texts?.first(where: { $0.name == block.textName }) else {
|
||||||
guard let defaultText = theme.texts?.first(where: { $0.name == "default" }) else {
|
guard let defaultText = theme.texts?.first(where: { $0.name == "default" }) else {
|
||||||
@@ -80,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ struct BarView: View {
|
|||||||
ForEach(bar.arrangedBlocks) { block in
|
ForEach(bar.arrangedBlocks) { block in
|
||||||
switch block.kind {
|
switch block.kind {
|
||||||
case .text:
|
case .text:
|
||||||
TextBlockView(block: block)
|
TextView(block: block)
|
||||||
case .space:
|
case .space:
|
||||||
SpaceBlockView(block: block)
|
SpaceView(block: block)
|
||||||
|
case .divider:
|
||||||
|
DividerView(block: block)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,14 +26,16 @@ struct BarView: View {
|
|||||||
ForEach(bar.centeredBlocks) { block in
|
ForEach(bar.centeredBlocks) { block in
|
||||||
switch block.kind {
|
switch block.kind {
|
||||||
case .text:
|
case .text:
|
||||||
TextBlockView(block: block)
|
TextView(block: block)
|
||||||
case .space:
|
case .space:
|
||||||
SpaceBlockView(block: block)
|
SpaceView(block: block)
|
||||||
|
case .divider:
|
||||||
|
DividerView(block: block)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 30)
|
.frame(height: bar.window.height ?? 30)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.environmentObject(bar)
|
.environmentObject(bar)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,48 @@ final class BarWindow: NSPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension BarWindow {
|
extension BarWindow {
|
||||||
/// Calculate window frame and position.
|
/// Recompute and apply the frame from the controller's screen and theme.
|
||||||
|
/// Used when a screen's geometry changes (resolution, layout) so the bar
|
||||||
|
/// stays correctly anchored without rebuilding the whole window.
|
||||||
|
func recomputeFrame() {
|
||||||
|
setFrame(Self.computeFrame(from: barController), display: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate window frame and position from the theme window definition,
|
||||||
|
/// resolved against the controller's screen.
|
||||||
static func computeFrame(from controller: BarController) -> NSRect {
|
static func computeFrame(from controller: BarController) -> NSRect {
|
||||||
let screenFrame = controller.screen.frame
|
let screenFrame = controller.screen.frame
|
||||||
let height: CGFloat = 30
|
let themeWindow = controller.window
|
||||||
let x = screenFrame.minX
|
|
||||||
let y = screenFrame.maxY - height
|
|
||||||
|
|
||||||
return NSRect(x: x, y: y, width: screenFrame.width, height: height)
|
// Absolute origin bypasses the anchor/margin system entirely: width
|
||||||
|
// resolves against the full screen and the window sits at the exact
|
||||||
|
// coordinates given.
|
||||||
|
if let origin = themeWindow.origin {
|
||||||
|
let size = CGSize(
|
||||||
|
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: screenFrame.width),
|
||||||
|
height: CGFloat(themeWindow.height ?? 30)
|
||||||
|
)
|
||||||
|
return NSRect(
|
||||||
|
x: CGFloat(origin.x),
|
||||||
|
y: CGFloat(origin.y),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Margins define a content rectangle within the screen: the window is
|
||||||
|
// sized against it (so `width = "100%"` spans only between the margins,
|
||||||
|
// not the full screen) and placed by the anchor inside it.
|
||||||
|
let margin = themeWindow.margin ?? .init()
|
||||||
|
let contentRect = margin.inset(of: screenFrame)
|
||||||
|
let size = CGSize(
|
||||||
|
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: contentRect.width),
|
||||||
|
height: CGFloat(themeWindow.height ?? 30)
|
||||||
|
)
|
||||||
|
let anchor = themeWindow.anchor ?? .top
|
||||||
|
return NSRect(
|
||||||
|
origin: anchor.origin(in: contentRect, size: size),
|
||||||
|
size: size
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DividerView: View {
|
||||||
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
|
var width: CGFloat {
|
||||||
|
guard let arg = block.name.split(separator: " ").last else { return 1 }
|
||||||
|
guard let width = Double(arg) else { return 1 }
|
||||||
|
return CGFloat(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundStyle(block.style.background?.uiColor ?? .clear)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if let corner = block.style.corner {
|
||||||
|
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
|
||||||
|
} else {
|
||||||
|
Rectangle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
|
||||||
|
.frame(width: width)
|
||||||
|
.padding(.trailing, block.style.padding?.trailing ?? 0)
|
||||||
|
.padding(.leading, block.style.padding?.leading ?? 0)
|
||||||
|
.padding(.top, block.style.padding?.top ?? 0)
|
||||||
|
.padding(.bottom, block.style.padding?.bottom ?? 0)
|
||||||
|
.border(block.debug ? .blue : .clear)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
.border(block.debug ? .red : .clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SpaceBlockView: View {
|
struct SpaceView: View {
|
||||||
@ObservedObject var block: PHBlock
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
var width: CGFloat? {
|
var width: CGFloat? {
|
||||||
guard let arg = block.command.split(separator: " ").last else { return nil }
|
guard let arg = block.name.split(separator: " ").last else { return nil }
|
||||||
guard let width = Double(arg) else { return nil }
|
guard let width = Double(arg) else { return nil }
|
||||||
return CGFloat(width)
|
return CGFloat(width)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TextBlockView: View {
|
struct TextView: View {
|
||||||
@EnvironmentObject private var bar: BarController
|
@EnvironmentObject private var bar: BarController
|
||||||
@ObservedObject var block: PHBlock
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
@@ -14,7 +14,13 @@ struct TextBlockView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if let label = block.label, !label.isEmpty {
|
if let label = block.label, !label.isEmpty {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
RoundedRectangle(cornerRadius: 0)
|
Group {
|
||||||
|
if let corner = block.style.corner {
|
||||||
|
RoundedRectangle(cornerRadius: corner.radius, style: corner.style)
|
||||||
|
} else {
|
||||||
|
Rectangle()
|
||||||
|
}
|
||||||
|
}
|
||||||
.foregroundStyle(block.style.background?.uiColor ?? .clear)
|
.foregroundStyle(block.style.background?.uiColor ?? .clear)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
@@ -23,13 +29,15 @@ struct TextBlockView: View {
|
|||||||
.italic(block.text.italic)
|
.italic(block.text.italic)
|
||||||
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
|
.foregroundStyle(block.style.foreground?.uiColor ?? .clear)
|
||||||
.transformEffect(adjustOffset)
|
.transformEffect(adjustOffset)
|
||||||
.border(bar.debug ? .blue : .clear)
|
.border(block.debug ? .blue : .clear)
|
||||||
}
|
}
|
||||||
.padding(.trailing, block.style.padding?.trailing ?? 0)
|
.padding(.trailing, block.style.padding?.trailing ?? 0)
|
||||||
.padding(.leading, block.style.padding?.leading ?? 0)
|
.padding(.leading, block.style.padding?.leading ?? 0)
|
||||||
|
.padding(.top, block.style.padding?.top ?? 0)
|
||||||
|
.padding(.bottom, block.style.padding?.bottom ?? 0)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.border(bar.debug ? .red : .clear)
|
.border(block.debug ? .red : .clear)
|
||||||
.onHover { hovering = $0 }
|
.onHover { hovering = $0 }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSEvent.addLocalMonitorForEvents(matching: [
|
NSEvent.addLocalMonitorForEvents(matching: [
|
||||||
@@ -39,7 +47,7 @@ struct TextBlockView: View {
|
|||||||
.scrollWheel,
|
.scrollWheel,
|
||||||
]) { event in
|
]) { event in
|
||||||
if hovering, let gestureEvent = PHGestureEvent.from(event) {
|
if hovering, let gestureEvent = PHGestureEvent.from(event) {
|
||||||
block.handleGesture(gestureEvent)
|
block.resolve(with: gestureEvent)
|
||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo " $(date "+%a %d, %H:%M:%S")"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "phbar v$(phbar --version)"
|
||||||
@@ -1,38 +1,36 @@
|
|||||||
# phbar theme file ~ Voltage
|
# phbar theme file
|
||||||
#
|
#
|
||||||
# Place at ~/.config/phbar/themes/voltage.toml
|
# Place at ~/.config/phbar/themes/default.toml
|
||||||
|
|
||||||
[window]
|
[[window]]
|
||||||
shadow = false
|
name = "default"
|
||||||
blur = 0.0
|
anchor = "top"
|
||||||
|
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 }
|
||||||
@@ -4,9 +4,9 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// phbar ships a set of built-in recognizers (volume, network, appearance,
|
/// phbar ships a set of built-in recognizers (volume, network, appearance,
|
||||||
/// power, mpd) compiled into the host. External recognizers are loaded at
|
/// power, mpd) compiled into the host. External recognizers are loaded at
|
||||||
/// runtime as shared libraries from `~/.config/phbar/events/<name>/` and conform
|
/// runtime as shared libraries from the events directory
|
||||||
/// to this same protocol, so the host drives built-in and external recognizers
|
/// (`<config>/events/<name>/`) and conform to this same protocol, so the host
|
||||||
/// through one shape.
|
/// drives built-in and external recognizers through one shape.
|
||||||
///
|
///
|
||||||
/// Conform an `NSObject` subclass and export a zero-argument factory via
|
/// Conform an `NSObject` subclass and export a zero-argument factory via
|
||||||
/// `@_cdecl(PHEventCreateSymbol)`:
|
/// `@_cdecl(PHEventCreateSymbol)`:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Refresh command
|
||||||
|
|
||||||
|
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
||||||
|
let config = PHBar.Refresh.configuration
|
||||||
|
#expect(config.commandName == "refresh")
|
||||||
|
#expect(config.abstract == "Refresh the running status bar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BarController refresh
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func controllerRefreshUpdatesAllBlocks() async throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf a"
|
||||||
|
name = "a"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "printf b"
|
||||||
|
name = "b"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let config = try PHConfig.load()
|
||||||
|
let theme = try PHTheme.load("voltage")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
controller.refresh()
|
||||||
|
|
||||||
|
// Allow the per-block update tasks to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(blocks[0].label == "a")
|
||||||
|
#expect(blocks[1].label == "b")
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Payload Types for Testing
|
||||||
|
|
||||||
|
private struct TestPayload: Codable, Equatable {
|
||||||
|
let message: String
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Name
|
||||||
|
|
||||||
|
@Test func notificationNameIsHashable() async throws {
|
||||||
|
let a = IPCNotificationName("com.example.test")
|
||||||
|
let b = IPCNotificationName("com.example.test")
|
||||||
|
let c = IPCNotificationName("com.example.other")
|
||||||
|
|
||||||
|
#expect(a == b)
|
||||||
|
#expect(a != c)
|
||||||
|
#expect(a.hashValue == b.hashValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
|
||||||
|
let name: IPCNotificationName = "com.example.test"
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationNameRawRepresentable() async throws {
|
||||||
|
let name = IPCNotificationName(rawValue: "com.example.test")
|
||||||
|
#expect(name.rawValue == "com.example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payload Decoding
|
||||||
|
|
||||||
|
@Test func decodePayloadFromNotification() async throws {
|
||||||
|
let payload = TestPayload(message: "hello", count: 42)
|
||||||
|
let userInfo: [String: Any] = ["message": "hello", "count": 42]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: nil)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodePayloadReturnsNilForInvalidData() async throws {
|
||||||
|
let userInfo: [String: Any] = ["wrong": "data"]
|
||||||
|
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
||||||
|
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
||||||
|
#expect(decoded == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Posting
|
||||||
|
|
||||||
|
@Test func postWithoutPayloadDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithUserInfoDoesNotCrash() async throws {
|
||||||
|
IPC.post(.refresh, userInfo: ["key": "value"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
|
||||||
|
let payload = TestPayload(message: "test", count: 1)
|
||||||
|
let result = IPC.post(.refresh, payload: payload)
|
||||||
|
#expect(result == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observer
|
||||||
|
|
||||||
|
@Test func observerCleansUpOnDeinit() async throws {
|
||||||
|
// Verify that creating and releasing an observer doesn't crash.
|
||||||
|
// The token removes itself from the distributed center on deinit.
|
||||||
|
let token = IPC.observe(.refresh) { _ in }
|
||||||
|
withExtendedLifetime(token) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksFromTOML() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hello"
|
||||||
|
name = "greeting"
|
||||||
|
refresh = 5.0
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
command = "echo world"
|
||||||
|
name = "world"
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks.count == 2)
|
||||||
|
#expect(blocks[0].command == "echo hello")
|
||||||
|
#expect(blocks[0].name == "greeting")
|
||||||
|
#expect(blocks[0].refresh == 5.0)
|
||||||
|
#expect(blocks[0].label == nil)
|
||||||
|
#expect(blocks[1].name == "world")
|
||||||
|
#expect(blocks[1].refresh == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadBlocksRejectsInvalidTOML() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load(from: "not = valid = toml = =")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Named set loading
|
||||||
|
|
||||||
|
private func makeBlocksConfigDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadSetReadsNamedFile() throws {
|
||||||
|
let dir = try makeBlocksConfigDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let blocksDir = dir.appending(path: "blocks")
|
||||||
|
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
|
||||||
|
try Data("""
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
name = "greeting"
|
||||||
|
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load("laptop", in: dir)
|
||||||
|
|
||||||
|
#expect(blocks.count == 1)
|
||||||
|
#expect(blocks[0].name == "greeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func loadSetThrowsForMissingSet() {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try PHBlock.load("nope", in: dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compute
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeSeesBlockEnvironment() async throws {
|
||||||
|
// The `[env]` section (overlaid by `BarController`) must reach the script's
|
||||||
|
// process environment — this is the wiring the `env` config documents.
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
name = "env"
|
||||||
|
command = "echo $PHBAR_TEST_VAR"
|
||||||
|
""")[0]
|
||||||
|
block.environment = PHEnvironment(variables: ["PHBAR_TEST_VAR": "panini"])
|
||||||
|
|
||||||
|
let output = await block.compute()
|
||||||
|
|
||||||
|
#expect(output == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeReturnsCommandStdout() async throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
name = "panini"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let output = await blocks[0].compute()
|
||||||
|
|
||||||
|
#expect(output == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeRunsInConfigDirectory() async throws {
|
||||||
|
// Block commands execute with the resolved config root (see `PHPaths`) as
|
||||||
|
// their working directory, so a block from any set resolves relative paths
|
||||||
|
// the same way. Guards against the config dir being absent in sandboxed CIs.
|
||||||
|
guard FileManager.default.fileExists(atPath: PHPaths.configDirectory.path) else { return }
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "pwd"
|
||||||
|
name = "pwd"
|
||||||
|
""")
|
||||||
|
|
||||||
|
let output = await blocks[0].compute()
|
||||||
|
|
||||||
|
// Resolve symlinks on both sides: the config dir is commonly a symlink into
|
||||||
|
// a dotfiles repo, and `pwd` reports the physical path.
|
||||||
|
let expected = PHPaths.configDirectory.resolvingSymlinksInPath().path
|
||||||
|
#expect(output == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-refresh
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func updateSetsLabel() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf panini"
|
||||||
|
name = "panini"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
#expect(block.label == nil)
|
||||||
|
|
||||||
|
await block.update()
|
||||||
|
|
||||||
|
#expect(block.label == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "printf hi"
|
||||||
|
name = "hi"
|
||||||
|
""")[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
// Allow the one-shot update task to run.
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
#expect(block.label == "hi")
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func startAutoRefreshRepeatsAtInterval() async throws {
|
||||||
|
// A counter file lets us observe how many times the command ran.
|
||||||
|
let counter = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "phbar_test_\(UUID().uuidString)")
|
||||||
|
defer { try? FileManager.default.removeItem(at: counter) }
|
||||||
|
|
||||||
|
let path = counter.path
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "counter"
|
||||||
|
refresh = 0.05
|
||||||
|
"""
|
||||||
|
|
||||||
|
let block = try PHBlock.load(from: toml)[0]
|
||||||
|
|
||||||
|
block.startAutoRefresh()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(250))
|
||||||
|
|
||||||
|
block.stopAutoRefresh()
|
||||||
|
|
||||||
|
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
|
|
||||||
|
// At ~20Hz over 250ms the command should have run more than once.
|
||||||
|
#expect(count >= 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Per-monitor resolution
|
||||||
|
//
|
||||||
|
// `PHConfig` exposes one resolution API: `theme`/`window`/`blocks`/
|
||||||
|
// `monitorOverride`, each taking a pure `(screenName, screenIndex)` pair.
|
||||||
|
// Precedence for every field: override-by-name → override-by-index → global.
|
||||||
|
|
||||||
|
/// Build a `PHConfig` with sensible defaults so each test only spells out the
|
||||||
|
/// fields it cares about.
|
||||||
|
private func makeConfig(
|
||||||
|
theme: String = "default",
|
||||||
|
window: String = "default",
|
||||||
|
blocks: String? = nil,
|
||||||
|
monitors: [String: PHConfigMonitorOverride]? = nil
|
||||||
|
) -> PHConfig {
|
||||||
|
PHConfig(theme: theme, window: window, blocks: blocks, env: nil, monitors: monitors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: window
|
||||||
|
|
||||||
|
@Test func windowFallsBackToGlobal() {
|
||||||
|
let config = makeConfig(window: "top")
|
||||||
|
#expect(config.window(screenName: "Built-in", screenIndex: 0) == "top")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowMatchesByIndex() {
|
||||||
|
let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)])
|
||||||
|
#expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowMatchesByName() {
|
||||||
|
let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)])
|
||||||
|
#expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func windowPrefersNameOverIndex() {
|
||||||
|
let config = makeConfig(monitors: [
|
||||||
|
"0": .init(window: "byIndex", layout: nil),
|
||||||
|
"DELL": .init(window: "byName", layout: nil),
|
||||||
|
])
|
||||||
|
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: blocks
|
||||||
|
|
||||||
|
@Test func blocksDefaultsToDefault() {
|
||||||
|
#expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func blocksUsesGlobalWhenNoOverride() {
|
||||||
|
let config = makeConfig(blocks: "main")
|
||||||
|
#expect(config.layout(screenName: "S", screenIndex: 0) == "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func blocksOverrideBeatsGlobal() {
|
||||||
|
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
|
||||||
|
#expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: theme
|
||||||
|
|
||||||
|
@Test func themeFallsBackToGlobal() {
|
||||||
|
let config = makeConfig(theme: "voltage")
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 0) == "voltage")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeOverrideBeatsGlobal() {
|
||||||
|
let config = makeConfig(theme: "voltage", monitors: ["1": .init(theme: "mono", window: nil, blocks: nil)])
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "mono")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: independence
|
||||||
|
|
||||||
|
@Test func overrideInheritsUnsetFieldsFromGlobal() {
|
||||||
|
// An override that sets only `blocks` still inherits the global window/theme.
|
||||||
|
let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")])
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
|
||||||
|
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
|
||||||
|
#expect(config.layout(screenName: "S", screenIndex: 1) == "laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeWindowAndBlocksResolveIndependently() {
|
||||||
|
let config = makeConfig(
|
||||||
|
theme: "voltage", window: "top", blocks: "main",
|
||||||
|
monitors: [
|
||||||
|
"DELL": .init(theme: "mono", window: "clock", blocks: nil), // inherits blocks "main"
|
||||||
|
"1": .init(theme: nil, window: nil, blocks: "laptop"), // inherits theme/window
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#expect(config.theme(screenName: "DELL", screenIndex: 0) == "mono")
|
||||||
|
#expect(config.window(screenName: "DELL", screenIndex: 0) == "clock")
|
||||||
|
#expect(config.blocks(screenName: "DELL", screenIndex: 0) == "main")
|
||||||
|
|
||||||
|
#expect(config.theme(screenName: "S", screenIndex: 1) == "voltage")
|
||||||
|
#expect(config.window(screenName: "S", screenIndex: 1) == "top")
|
||||||
|
#expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: monitorOverride
|
||||||
|
|
||||||
|
@Test func monitorOverrideReturnsNilWhenTableAbsent() {
|
||||||
|
#expect(makeConfig().monitorOverride(screenName: "S", screenIndex: 0) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func monitorOverrideReturnsNilForUnmatchedScreen() {
|
||||||
|
let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)])
|
||||||
|
#expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
|
||||||
|
}
|
||||||
@@ -1,258 +1,9 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import phbar
|
@testable import phbar
|
||||||
|
|
||||||
// MARK: - Payload Types for Testing
|
// MARK: - Fake event source
|
||||||
|
|
||||||
private struct TestPayload: Codable, Equatable {
|
|
||||||
let message: String
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Name Tests
|
|
||||||
|
|
||||||
@Test func notificationNameIsHashable() async throws {
|
|
||||||
let a = IPCNotificationName("com.example.test")
|
|
||||||
let b = IPCNotificationName("com.example.test")
|
|
||||||
let c = IPCNotificationName("com.example.other")
|
|
||||||
|
|
||||||
#expect(a == b)
|
|
||||||
#expect(a != c)
|
|
||||||
#expect(a.hashValue == b.hashValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func notificationNameIsExpressibleByStringLiteral() async throws {
|
|
||||||
let name: IPCNotificationName = "com.example.test"
|
|
||||||
#expect(name.rawValue == "com.example.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func notificationNameRawRepresentable() async throws {
|
|
||||||
let name = IPCNotificationName(rawValue: "com.example.test")
|
|
||||||
#expect(name.rawValue == "com.example.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Payload Decoding Tests
|
|
||||||
|
|
||||||
@Test func decodePayloadFromNotification() async throws {
|
|
||||||
let payload = TestPayload(message: "hello", count: 42)
|
|
||||||
let userInfo: [String: Any] = ["message": "hello", "count": 42]
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
|
||||||
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decodePayloadReturnsNilForMissingUserInfo() async throws {
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: nil)
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decodePayloadReturnsNilForInvalidData() async throws {
|
|
||||||
let userInfo: [String: Any] = ["wrong": "data"]
|
|
||||||
let notification = IPCNotification(name: .refresh, userInfo: userInfo)
|
|
||||||
let decoded: TestPayload? = notification.decode(TestPayload.self)
|
|
||||||
#expect(decoded == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Posting Tests
|
|
||||||
|
|
||||||
@Test func postWithoutPayloadDoesNotCrash() async throws {
|
|
||||||
IPC.post(.refresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func postWithUserInfoDoesNotCrash() async throws {
|
|
||||||
IPC.post(.refresh, userInfo: ["key": "value"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func postWithEncodablePayloadDoesNotCrash() async throws {
|
|
||||||
let payload = TestPayload(message: "test", count: 1)
|
|
||||||
let result = IPC.post(.refresh, payload: payload)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observer Tests
|
|
||||||
|
|
||||||
@Test func observerCleansUpOnDeinit() async throws {
|
|
||||||
// Verify that creating and releasing an observer doesn't crash.
|
|
||||||
// The token removes itself from the distributed center on deinit.
|
|
||||||
let token = IPC.observe(.refresh) { _ in }
|
|
||||||
withExtendedLifetime(token) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Refresh Command Config
|
|
||||||
|
|
||||||
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
|
||||||
let config = phbar.refresh.configuration
|
|
||||||
#expect(config.commandName == "refresh")
|
|
||||||
#expect(config.abstract == "Refresh the running status bar.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock Loading & Compute
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadBlocksFromTOML() throws {
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hello"
|
|
||||||
name = "greeting"
|
|
||||||
refresh = 5.0
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
command = "echo world"
|
|
||||||
"""
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
|
||||||
|
|
||||||
#expect(blocks.count == 2)
|
|
||||||
#expect(blocks[0].command == "echo hello")
|
|
||||||
#expect(blocks[0].name == "greeting")
|
|
||||||
#expect(blocks[0].refresh == 5.0)
|
|
||||||
#expect(blocks[0].label == "")
|
|
||||||
#expect(blocks[1].name == nil)
|
|
||||||
#expect(blocks[1].refresh == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func loadBlocksRejectsInvalidTOML() {
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try PHBlock.load(from: "not = valid = toml = =")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func computeReturnsCommandStdout() async throws {
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf panini"
|
|
||||||
""")
|
|
||||||
|
|
||||||
let output = await blocks[0].compute()
|
|
||||||
|
|
||||||
#expect(output == "panini")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHBlock Refresh
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func updateSetsLabel() async throws {
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf panini"
|
|
||||||
""")[0]
|
|
||||||
|
|
||||||
#expect(block.label == "")
|
|
||||||
|
|
||||||
await block.update()
|
|
||||||
|
|
||||||
#expect(block.label == "panini")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func startAutoRefreshWithoutIntervalComputesOnce() async throws {
|
|
||||||
let block = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf hi"
|
|
||||||
""")[0]
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
|
|
||||||
// Allow the one-shot update task to run.
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(block.label == "hi")
|
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func startAutoRefreshRepeatsAtInterval() async throws {
|
|
||||||
// A counter file lets us observe how many times the command ran.
|
|
||||||
let counter = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "phbar_test_\(UUID().uuidString)")
|
|
||||||
defer { try? FileManager.default.removeItem(at: counter) }
|
|
||||||
|
|
||||||
let path = counter.path
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
|
||||||
refresh = 0.05
|
|
||||||
"""
|
|
||||||
|
|
||||||
let block = try PHBlock.load(from: toml)[0]
|
|
||||||
|
|
||||||
block.startAutoRefresh()
|
|
||||||
|
|
||||||
try await Task.sleep(for: .milliseconds(250))
|
|
||||||
|
|
||||||
block.stopAutoRefresh()
|
|
||||||
|
|
||||||
let count = Int(block.label!.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
|
||||||
|
|
||||||
// At ~20Hz over 250ms the command should have run more than once.
|
|
||||||
#expect(count >= 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHEvent
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventDecodesFromKnownStrings() throws {
|
|
||||||
let toml = """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
events = ["volume", "network", "appearance", "power", "mpd"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
|
||||||
|
|
||||||
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventAcceptsArbitraryStringAsCustom() throws {
|
|
||||||
// Unknown strings no longer fail decoding: they become custom event names
|
|
||||||
// resolved at runtime against ~/.config/phbar/events/<name>/.
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
events = ["totally_made_up"]
|
|
||||||
""")
|
|
||||||
|
|
||||||
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventsOptionalWhenOmitted() throws {
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "echo hi"
|
|
||||||
""")
|
|
||||||
|
|
||||||
#expect(blocks[0].events == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
|
|
||||||
// No recognizer installed at a clean directory → factory yields nil.
|
|
||||||
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
|
||||||
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
|
||||||
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
|
|
||||||
return PHExternalEventSource(recognizer: recognizer)
|
|
||||||
}
|
|
||||||
#expect(factory(PHEvent("anything")) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func eventLoaderReturnsNilForMissingEvent() {
|
|
||||||
// A clean directory has no event folders, so every name fails to load.
|
|
||||||
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
|
||||||
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
|
|
||||||
#expect(recognizer == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PHEventRegistry
|
|
||||||
|
|
||||||
/// A no-op source that records its lifecycle and can be fired on demand.
|
/// A no-op source that records its lifecycle and can be fired on demand.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -274,6 +25,70 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
func fire() { notify?() }
|
func fire() { notify?() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Event decoding
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventDecodesFromKnownStrings() throws {
|
||||||
|
let toml = """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
name = "a"
|
||||||
|
events = ["volume", "network", "appearance", "power", "mpd"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
let blocks = try PHBlock.load(from: toml)
|
||||||
|
|
||||||
|
#expect(blocks[0].events == [PHEvent("volume"), PHEvent("network"), PHEvent("appearance"), PHEvent("power"), PHEvent("mpd")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventAcceptsArbitraryStringAsCustom() throws {
|
||||||
|
// Unknown strings become custom event names resolved at runtime against
|
||||||
|
// `<config>/events/<name>/`.
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
name = "b"
|
||||||
|
events = ["totally_made_up"]
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(blocks[0].events == [PHEvent("totally_made_up")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventsOptionalWhenOmitted() throws {
|
||||||
|
let blocks = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
command = "echo hi"
|
||||||
|
name = "c"
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(blocks[0].events == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event loader
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func defaultFactoryReturnsNilWithoutRecognizer() {
|
||||||
|
// No recognizer installed at a clean directory → factory yields nil.
|
||||||
|
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
||||||
|
let factory: @MainActor @Sendable (PHEvent) -> (any PHEventSource)? = { event in
|
||||||
|
guard let recognizer = loader.recognizer(for: event.rawValue) else { return nil }
|
||||||
|
return PHExternalEventSource(recognizer: recognizer)
|
||||||
|
}
|
||||||
|
#expect(factory(PHEvent("anything")) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func eventLoaderReturnsNilForMissingEvent() {
|
||||||
|
// A clean directory has no event folders, so every name fails to load.
|
||||||
|
let loader = PHEventLoader(directory: FileManager.default.temporaryDirectory)
|
||||||
|
let recognizer = loader.recognizer(for: "does-not-exist-\(UUID().uuidString)")
|
||||||
|
#expect(recognizer == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event registry
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
|
@Test func registryActivatesSourceLazilyAndRefcounts() async throws {
|
||||||
let fake = FakeEventSource()
|
let fake = FakeEventSource()
|
||||||
@@ -334,7 +149,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
n.cancel()
|
n.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PHBlock + Events
|
// MARK: - Block + events
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func blockRefreshesOnEvent() async throws {
|
@Test func blockRefreshesOnEvent() async throws {
|
||||||
@@ -349,6 +164,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "d"
|
||||||
events = ["volume"]
|
events = ["volume"]
|
||||||
""")[0]
|
""")[0]
|
||||||
block.registry = registry
|
block.registry = registry
|
||||||
@@ -385,6 +201,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "e"
|
||||||
refresh = 0.2
|
refresh = 0.2
|
||||||
events = ["volume"]
|
events = ["volume"]
|
||||||
""")[0]
|
""")[0]
|
||||||
@@ -406,30 +223,3 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
|
|
||||||
block.stopAutoRefresh()
|
block.stopAutoRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PHController
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func controllerRefreshUpdatesAllBlocks() async throws {
|
|
||||||
let screen = try #require(NSScreen.main)
|
|
||||||
let blocks = try PHBlock.load(from: """
|
|
||||||
[[block]]
|
|
||||||
command = "printf a"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
command = "printf b"
|
|
||||||
""")
|
|
||||||
|
|
||||||
let config = try PHConfig.load()
|
|
||||||
let theme = try PHTheme.load("voltage")
|
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks)
|
|
||||||
|
|
||||||
controller.refresh()
|
|
||||||
|
|
||||||
// Allow the per-block update tasks to run.
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(blocks[0].label == "a")
|
|
||||||
#expect(blocks[1].label == "b")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - Config directory cascade
|
||||||
|
//
|
||||||
|
// `PHPaths` resolves the config root in priority order:
|
||||||
|
// 1. $XDG_CONFIG_HOME/phbar (only for an absolute XDG path)
|
||||||
|
// 2. ~/.config/phbar
|
||||||
|
// 3. ~/.phbar
|
||||||
|
|
||||||
|
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "/custom/xdg"])
|
||||||
|
|
||||||
|
#expect(candidates.count == 3)
|
||||||
|
#expect(candidates[0].path == "/custom/xdg/phbar")
|
||||||
|
#expect(candidates[1].path == home.appending(path: ".config/phbar").path)
|
||||||
|
#expect(candidates[2].path == home.appending(path: ".phbar").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitRelativeXdg() {
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": "relative/path"])
|
||||||
|
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
#expect(candidates[0].path == home.appending(path: ".config/phbar").path)
|
||||||
|
#expect(candidates[1].path == home.appending(path: ".phbar").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitEmptyXdg() {
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: ["XDG_CONFIG_HOME": ""])
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsCandidatesOmitXdgWhenUnset() {
|
||||||
|
let candidates = PHPaths.configDirectoryCandidates(environment: [:])
|
||||||
|
#expect(candidates.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsResolvePicksFirstExistingCandidate() throws {
|
||||||
|
// An existing XDG-rooted dir takes priority over ~/.config/phbar because
|
||||||
|
// it sorts first in the candidate list.
|
||||||
|
let xdgRoot = FileManager.default.temporaryDirectory.appending(path: "phbar_xdg_\(UUID().uuidString)")
|
||||||
|
let xdgConfig = xdgRoot.appending(path: "phbar")
|
||||||
|
try FileManager.default.createDirectory(at: xdgConfig, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: xdgRoot) }
|
||||||
|
|
||||||
|
let resolved = PHPaths.resolveConfigDirectory(environment: ["XDG_CONFIG_HOME": xdgRoot.path])
|
||||||
|
|
||||||
|
#expect(resolved == xdgConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pathsConfigDirectoryMatchesResolve() {
|
||||||
|
// The cached static agrees with a fresh resolve against the live process env.
|
||||||
|
#expect(PHPaths.configDirectory == PHPaths.resolveConfigDirectory())
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowDimension
|
||||||
|
|
||||||
|
private struct DimensionWrapper: Decodable {
|
||||||
|
let v: PHThemeWindowDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionDecodesPoints() throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionDecodesPercentage() throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
|
||||||
|
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionRejectsGarbage() {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dimensionResolvesAgainstLength() {
|
||||||
|
#expect(PHThemeWindowDimension.points(250).resolve(against: 1000) == 250)
|
||||||
|
#expect(PHThemeWindowDimension.percentage(0.25).resolve(against: 1000) == 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowAnchor geometry
|
||||||
|
|
||||||
|
@Test func anchorPlacesAtTopEdge() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 1000, height: 30)
|
||||||
|
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 0, y: 770))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorRespectsTopMargin() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 1000, height: 30)
|
||||||
|
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 0, y: 760))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorPlacesBottomLeadingCorner() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 12, y: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorTrailingCentersVertically() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
|
||||||
|
#expect(origin == CGPoint(x: 780, y: 380))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorCentersWithinContentRect() {
|
||||||
|
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let size = CGSize(width: 200, height: 40)
|
||||||
|
// `.center` centers within the inset rect: a top margin lifts the center,
|
||||||
|
// it doesn't ignore the margin.
|
||||||
|
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
|
||||||
|
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
|
||||||
|
// rect: y ∈ [0, 700], midY = 350 → 350 − 20 = 330
|
||||||
|
#expect(origin == CGPoint(x: 400, y: 330))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHThemeWindowMargin.inset
|
||||||
|
|
||||||
|
@Test func marginInsetAppliesAllEdges() {
|
||||||
|
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
let inset = PHThemeWindowMargin(top: 10, bottom: 20, leading: 30, trailing: 40).inset(of: rect)
|
||||||
|
#expect(inset == CGRect(x: 30, y: 20, width: 930, height: 770))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func marginInsetLeavesUnspecifiedEdgesUntouched() {
|
||||||
|
let rect = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||||
|
// Only leading set; top/bottom/trailing are left as-is (treated as 0).
|
||||||
|
let inset = PHThemeWindowMargin(leading: 50).inset(of: rect)
|
||||||
|
#expect(inset == CGRect(x: 50, y: 0, width: 950, height: 800))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BarWindow.computeFrame
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
// A window that omits geometry exercises computeFrame's defaults:
|
||||||
|
// width 100%, top anchor, no margin → a full-screen top bar. Built locally
|
||||||
|
// (rather than loading the bundled theme) so the test tracks the engine's
|
||||||
|
// defaults, not the theme file's editorial margins.
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(name: "default")],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.width == screen.frame.width)
|
||||||
|
#expect(frame.minX == screen.frame.minX)
|
||||||
|
#expect(frame.minY == screen.frame.maxY - 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemeWindowPoint(x: 100, y: 200))],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.origin == CGPoint(x: 100, y: 200))
|
||||||
|
#expect(frame.height == 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
|
||||||
|
// Regression: `width = "100%"` previously ignored leading/trailing margins
|
||||||
|
// and spanned edge to edge. Margins now inset the content rectangle, so the
|
||||||
|
// bar spans only between them.
|
||||||
|
let screen = try #require(NSScreen.main)
|
||||||
|
let sf = screen.frame
|
||||||
|
let theme = PHTheme(
|
||||||
|
windows: [PHThemeWindow(
|
||||||
|
name: "inset",
|
||||||
|
width: .percentage(1.0),
|
||||||
|
anchor: .top,
|
||||||
|
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
|
||||||
|
)],
|
||||||
|
texts: nil,
|
||||||
|
styles: nil
|
||||||
|
)
|
||||||
|
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
|
||||||
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|
||||||
|
#expect(frame.width == sf.width - 40)
|
||||||
|
#expect(frame.minX == sf.minX + 20)
|
||||||
|
#expect(frame.maxX == sf.maxX - 20)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user