refactor block resolve and refresh

This commit is contained in:
2026-07-13 23:05:08 +02:00
parent 30be4b2901
commit f479499309
6 changed files with 137 additions and 86 deletions
+3 -5
View File
@@ -5,7 +5,6 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let id = UUID() let id = UUID()
let name: String let name: String
let _command: String?
let textName: String? let textName: String?
@Published var text: PHThemeText = .default @Published var text: PHThemeText = .default
let styleName: String? let styleName: String?
@@ -14,8 +13,8 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
let centered: Bool? let centered: Bool?
var debug: Bool = false var debug: Bool = false
var command: String { nonisolated var command: String {
_command ?? PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
} }
var visible: Bool { var visible: Bool {
@@ -44,11 +43,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
/// Guards against stacking concurrent updates during rapid event bursts /// Guards against stacking concurrent updates during rapid event bursts
/// (e.g. dragging the volume slider fires many events in quick succession). /// (e.g. dragging the volume slider fires many events in quick succession).
var updateScheduled = false var resolveScheduled = false
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
case _command = "command"
case textName = "text" case textName = "text"
case styleName = "style" case styleName = "style"
case refresh, events, centered case refresh, events, centered
@@ -0,0 +1,11 @@
extension PHBlock {
func resolve(with event: PHGestureEvent) {
var environment = [String: String]()
environment.updateValue(event.type.rawValue, forKey: PHGestureEvent.typeKey)
if let data = event.data {
environment.updateValue(data, forKey: PHGestureEvent.infoKey)
}
resolve(merging: environment)
}
}
@@ -19,7 +19,7 @@ extension PHBlock {
stopAutoRefresh() stopAutoRefresh()
// Immediate refresh so the bar isn't blank until the first trigger fires. // Immediate refresh so the bar isn't blank until the first trigger fires.
scheduleUpdate() resolve()
// 1. Periodic refresh. // 1. Periodic refresh.
if let interval = refresh, interval > 0 { if let interval = refresh, interval > 0 {
@@ -28,7 +28,7 @@ extension PHBlock {
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(milliseconds)) try? await Task.sleep(for: .milliseconds(milliseconds))
if Task.isCancelled { break } if Task.isCancelled { break }
await self?.update() await self?.resolve()
} }
} }
} }
@@ -37,7 +37,7 @@ extension PHBlock {
// system listener only while at least one block subscribes to it. // system listener only while at least one block subscribes to it.
for event in (events ?? []) { for event in (events ?? []) {
let subscription = registry.subscribe(event) { [weak self] in let subscription = registry.subscribe(event) { [weak self] in
self?.scheduleUpdate() self?.resolve()
} }
subscriptions.append(subscription) subscriptions.append(subscription)
} }
@@ -50,80 +50,4 @@ extension PHBlock {
subscriptions.forEach { $0.cancel() } subscriptions.forEach { $0.cancel() }
subscriptions.removeAll() subscriptions.removeAll()
} }
/// Recompute the label from `command`. Used by the interval loop, event
/// handlers, and manual updates.
func update() async {
label = await compute()
}
/// Request an update on the main actor, coalescing rapid bursts into a single
/// recomputation.
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
Task { [weak self] in
defer { self?.updateScheduled = false }
await self?.update()
}
}
func handleGesture(_ event: PHGestureEvent) {
Task { label = await compute(with: event) }
}
/// Runs the provided command and returns its stdout.
///
/// Runs off the main actor so it can be awaited safely from SwiftUI views
/// without blocking the UI.
///
/// - Returns: A non-optional String to use as the block label.
func compute(with gestureEvent: PHGestureEvent? = nil) async -> String? {
guard kind == .text else { return nil }
let command = command
let environment = self.environment
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process()
// Commands run with the resolved config root (see `PHPaths`) as their
// working directory, regardless of which block set they belong to, so
// relative paths in user scripts stay stable.
process.currentDirectoryURL = PHPaths.configDirectory
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
// Scripts inherit phbar's process environment with the `[env]` section
// overlaid, so user-defined variables and PATH extensions are visible.
process.environment = environment.variables
if let gestureEvent {
let type = gestureEvent.type.rawValue
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
if let data = gestureEvent.data {
process.environment?.updateValue(data, forKey: PHGestureEvent.infoKey)
}
}
// Capture stdout via a pipe (otherwise standardOutput is always nil).
let pipe = Pipe()
process.standardOutput = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let string = String(data: data, encoding: .utf8)
guard let lines = string?.split(whereSeparator: \.isNewline) else { return nil }
return lines
} catch {
// Silently ignore failures
return nil
}
}.value
guard let lines else { return nil }
if self.debug { for line in lines { print(line) } }
return lines.last?.description
}
} }
@@ -0,0 +1,118 @@
import Foundation
extension PHBlock {
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) {
guard canResolve() else { return }
guard !resolveScheduled else { return }
resolveScheduled = true
Task(name: command, priority: .userInitiated) { [weak self] in
defer { self?.resolveScheduled = false }
do {
self?.label = try await self?.resolve(merging: environment)
} catch {
self?.printStandardError(error.localizedDescription)
}
}
}
/// Resolve the label by executing `command`.
/// Used by the interval loop, event handlers, and manual updates.
func resolve(merging environment: [String: String]? = nil) async {
guard canResolve() else { return }
do {
label = try await resolve(merging: environment)
} catch {
printStandardError(error.localizedDescription)
}
}
private func resolve(merging environment: [String: String]?) async throws -> String? {
let customEnvironment = environment
var environment = ProcessInfo.processInfo.environment
if let customEnvironment {
environment.merge(customEnvironment) { (_, new) in new }
}
return try await resolve(with: environment)
}
private func resolve(with environment: [String: String]) async throws -> String? {
do {
let (stdout, stderr) = try await resolve(command, with: environment)
printStandardError(stderr)
printStandardOutput(stdout)
guard let lines = stdout?.split(whereSeparator: \.isNewline),
let last = lines.last
else {
return nil
}
return String(last)
} catch {
throw error
}
}
private func canResolve() -> Bool {
guard kind == .text else { return false }
return true
}
private func resolve(_ command: String, with environment: [String: String]) async throws -> (
stdout: String?,
stderr: String?
) {
return try await withCheckedThrowingContinuation { continuation in
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.currentDirectoryURL = PHPaths.configDirectory
process.environment = environment
process.arguments = ["-c", command]
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: stdoutData, encoding: .utf8)
let stderr = String(data: stderrData, encoding: .utf8)
continuation.resume(returning: (stdout, stderr))
} catch {
continuation.resume(throwing: error)
}
}
}
/// Print a message to standard output.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardOutput(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardOutput.write(data)
}
/// Print a message to standard error.
/// Silenced unless in debug mode.
///
/// - Parameter message: the content of the message
private func printStandardError(_ message: String?) {
guard debug, let data = message?.data(using: .utf8) else { return }
FileHandle.standardError.write(data)
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ extension BarController {
/// Each block updates concurrently; the view refreshes as results arrive. /// Each block updates concurrently; the view refreshes as results arrive.
func refresh() { func refresh() {
for block in blocks { for block in blocks {
Task { await block.update() } block.resolve()
} }
} }
} }
+1 -1
View File
@@ -47,7 +47,7 @@ struct TextView: View {
.scrollWheel, .scrollWheel,
]) { event in ]) { event in
if hovering, let gestureEvent = PHGestureEvent.from(event) { if hovering, let gestureEvent = PHGestureEvent.from(event) {
block.handleGesture(gestureEvent) block.resolve(with: gestureEvent)
} }
return event return event
} }