move files around

This commit is contained in:
2026-07-12 15:06:51 +02:00
parent a6a7f80cce
commit 24c53eb51d
35 changed files with 27 additions and 27 deletions
@@ -0,0 +1,52 @@
import Foundation
import TOML
extension PHBlock {
private struct Wrapper: Decodable {
let blocks: [PHBlock]
private enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
/// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
///
/// - Parameter configDirectory: Override the lookup root (used by tests);
/// defaults to the resolved config directory (see `PHPaths`).
static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] {
let base = configDirectory ?? PHPaths.configDirectory
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
throw phbar.Error("Failed to load blocks '\(blocks)'")
}
return try load(from: setFile)
}
/// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted.
static func load(from url: URL) throws -> [PHBlock] {
do {
let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
throw phbar.Error("the file is empty")
}
return try load(from: contents)
} catch {
throw phbar.Error("Failed to load blocks file", underlyingError: error)
}
}
/// Load theme from a TOML string.
/// If the content fails to parse, the execution is interrupted.
static func load(from contents: String) throws -> [PHBlock] {
do {
let decoder = TOMLDecoder()
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
return wrapper.blocks
} catch {
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
}
}
}
@@ -0,0 +1,127 @@
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()
// Commands run with the resolved config root (see `PHPaths`) as their
// working directory, regardless of which block set they belong to, so
// relative paths in user scripts stay stable.
process.currentDirectoryURL = PHPaths.configDirectory
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
// Inherit the parent's environment (includes $PATH, etc.)
process.environment = ProcessInfo.processInfo.environment
if let gestureEvent {
let type = gestureEvent.type.rawValue
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
if let data = gestureEvent.data {
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
}
}
// Capture stdout via a pipe (otherwise standardOutput is always nil).
let pipe = Pipe()
process.standardOutput = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let string = String(data: data, encoding: .utf8)
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
return lines
} catch {
// Silently ignore failures
return nil
}
}.value
guard let lines else { return nil }
if self.debug { for line in lines { print(line) } }
return lines.last?.description
}
}
@@ -0,0 +1,10 @@
enum PHBlockKind: String {
case text, space
}
extension PHBlock {
var kind: PHBlockKind {
if command.starts(with: "_space") { return .space }
return .text
}
}